From c1c348d11b193d895b897a4508d5f744172c5711 Mon Sep 17 00:00:00 2001
From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com>
Date: Wed, 22 Jul 2026 11:19:41 +0200
Subject: [PATCH 01/15] feat(skoda): cookie jar + redirect follower for VW
identity login
---
src/services/skoda/skodaHttp.js | 60 +++++++++++++++++++++++++++++++
tests/skoda_http.test.js | 64 +++++++++++++++++++++++++++++++++
2 files changed, 124 insertions(+)
create mode 100644 src/services/skoda/skodaHttp.js
create mode 100644 tests/skoda_http.test.js
diff --git a/src/services/skoda/skodaHttp.js b/src/services/skoda/skodaHttp.js
new file mode 100644
index 00000000..af93dd3e
--- /dev/null
+++ b/src/services/skoda/skodaHttp.js
@@ -0,0 +1,60 @@
+'use strict';
+
+// Minimal in-memory cookie jar + manual redirect follower for the VW identity
+// login flow (HTML form posts + 302 chains across hosts). Native fetch has no
+// cookie handling, so this exists. Per-login lifetime only, never persisted.
+
+class CookieJar {
+ constructor() {
+ this.cookies = new Map(); // `${host}|${name}` -> value
+ }
+
+ storeFrom(res, url) {
+ const getSetCookie = typeof res.headers.getSetCookie === 'function'
+ ? res.headers.getSetCookie()
+ : [];
+ const host = new URL(url).host;
+ for (const line of getSetCookie) {
+ const pair = line.split(';')[0];
+ const eq = pair.indexOf('=');
+ if (eq < 1) continue;
+ this.cookies.set(`${host}|${pair.slice(0, eq).trim()}`, pair.slice(eq + 1).trim());
+ }
+ }
+
+ headerFor(url) {
+ const host = new URL(url).host;
+ const parts = [];
+ for (const [key, value] of this.cookies) {
+ const sep = key.indexOf('|');
+ if (key.slice(0, sep) === host) parts.push(`${key.slice(sep + 1)}=${value}`);
+ }
+ return parts.length ? parts.join('; ') : null;
+ }
+}
+
+async function requestWithJar(jar, url, opts = {}, fetchImpl = fetch) {
+ const headers = { ...(opts.headers || {}) };
+ const cookie = jar.headerFor(url);
+ if (cookie) headers.cookie = cookie;
+ const res = await fetchImpl(url, { ...opts, headers, redirect: 'manual' });
+ jar.storeFrom(res, url);
+ return res;
+}
+
+async function followRedirects(jar, url, opts, { maxHops = 15, stopPrefix = null, fetchImpl = fetch } = {}) {
+ let current = url;
+ let init = opts || {};
+ for (let hop = 0; hop <= maxHops; hop++) {
+ const res = await requestWithJar(jar, current, init, fetchImpl);
+ const loc = res.headers.get('location');
+ if (res.status < 300 || res.status >= 400 || !loc) return { res, location: current };
+ const next = /^[a-z][a-z0-9+.-]*:/i.test(loc) ? loc : new URL(loc, current).toString();
+ if (stopPrefix && next.startsWith(stopPrefix)) return { res, location: next };
+ current = next;
+ init = { method: 'GET' };
+ }
+ throw new Error('too many redirects');
+}
+
+module.exports = { CookieJar, requestWithJar, followRedirects };
diff --git a/tests/skoda_http.test.js b/tests/skoda_http.test.js
new file mode 100644
index 00000000..2dfcf5be
--- /dev/null
+++ b/tests/skoda_http.test.js
@@ -0,0 +1,64 @@
+'use strict';
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const { CookieJar, requestWithJar, followRedirects } = require('../src/services/skoda/skodaHttp');
+
+function fakeRes({ status = 200, headers = {}, setCookies = [] } = {}) {
+ const h = new Headers(headers);
+ for (const c of setCookies) h.append('set-cookie', c);
+ return { status, headers: h };
+}
+
+test('CookieJar stores cookies per host and builds header', () => {
+ const jar = new CookieJar();
+ jar.storeFrom(fakeRes({ setCookies: ['SESSION=abc; Path=/; HttpOnly', 'csrf=x1; Path=/'] }), 'https://identity.vwgroup.io/oidc/v1/authorize');
+ jar.storeFrom(fakeRes({ setCookies: ['other=zzz'] }), 'https://mysmob.api.connect.skoda-auto.cz/x');
+ assert.equal(jar.headerFor('https://identity.vwgroup.io/signin'), 'SESSION=abc; csrf=x1');
+ assert.equal(jar.headerFor('https://mysmob.api.connect.skoda-auto.cz/y'), 'other=zzz');
+ assert.equal(jar.headerFor('https://example.com/'), null);
+});
+
+test('CookieJar overwrites cookie with same name', () => {
+ const jar = new CookieJar();
+ jar.storeFrom(fakeRes({ setCookies: ['SESSION=old'] }), 'https://a.example/');
+ jar.storeFrom(fakeRes({ setCookies: ['SESSION=new'] }), 'https://a.example/');
+ assert.equal(jar.headerFor('https://a.example/'), 'SESSION=new');
+});
+
+test('requestWithJar sends cookie header and stores new cookies', async () => {
+ const jar = new CookieJar();
+ jar.storeFrom(fakeRes({ setCookies: ['a=1'] }), 'https://a.example/');
+ let seenHeaders = null;
+ const fetchImpl = async (url, opts) => { seenHeaders = opts.headers; return fakeRes({ setCookies: ['b=2'] }); };
+ await requestWithJar(jar, 'https://a.example/next', {}, fetchImpl);
+ assert.equal(seenHeaders.cookie, 'a=1');
+ assert.equal(jar.headerFor('https://a.example/'), 'a=1; b=2');
+});
+
+test('followRedirects follows 302 chain and stops at stopPrefix', async () => {
+ const jar = new CookieJar();
+ const hops = [
+ fakeRes({ status: 302, headers: { location: 'https://b.example/step2' }, setCookies: ['s1=1'] }),
+ fakeRes({ status: 302, headers: { location: '/step3' } }),
+ fakeRes({ status: 302, headers: { location: 'myskoda://redirect/login/#code=THECODE' } }),
+ ];
+ let calls = [];
+ const fetchImpl = async (url, opts) => { calls.push({ url, method: opts.method || 'GET' }); return hops.shift(); };
+ const { location } = await followRedirects(jar, 'https://a.example/start', { method: 'POST', body: 'x' },
+ { stopPrefix: 'myskoda://', fetchImpl });
+ assert.equal(location, 'myskoda://redirect/login/#code=THECODE');
+ assert.equal(calls.length, 3);
+ assert.equal(calls[0].method, 'POST');
+ assert.equal(calls[1].method, 'GET'); // Redirects werden als GET gefolgt
+ assert.equal(calls[1].url, 'https://b.example/step2');
+ assert.equal(calls[2].url, 'https://b.example/step3'); // relative Location aufgelöst
+});
+
+test('followRedirects throws after maxHops', async () => {
+ const jar = new CookieJar();
+ const fetchImpl = async () => fakeRes({ status: 302, headers: { location: 'https://a.example/loop' } });
+ await assert.rejects(
+ followRedirects(jar, 'https://a.example/', {}, { maxHops: 3, fetchImpl }),
+ /too many redirects/
+ );
+});
From dccca60c6b053098062188938bbb3d4fced4e51d Mon Sep 17 00:00:00 2001
From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com>
Date: Wed, 22 Jul 2026 11:23:35 +0200
Subject: [PATCH 02/15] feat(skoda): VW identity PKCE login flow + token
refresh
---
src/services/skoda/skodaAuth.js | 120 +++++++++++++++++++++++++
tests/fixtures/skoda/idk_login_page.js | 19 ++++
tests/skoda_auth.test.js | 108 ++++++++++++++++++++++
3 files changed, 247 insertions(+)
create mode 100644 src/services/skoda/skodaAuth.js
create mode 100644 tests/fixtures/skoda/idk_login_page.js
create mode 100644 tests/skoda_auth.test.js
diff --git a/src/services/skoda/skodaAuth.js b/src/services/skoda/skodaAuth.js
new file mode 100644
index 00000000..76ce38f8
--- /dev/null
+++ b/src/services/skoda/skodaAuth.js
@@ -0,0 +1,120 @@
+'use strict';
+
+// MySkoda login flow, ported from the Python `myskoda` reference library.
+// The API is unofficial; scripts/skoda-spike.js is the live ground truth.
+
+const crypto = require('node:crypto');
+const { CookieJar, requestWithJar, followRedirects } = require('./skodaHttp');
+
+const CLIENT_ID = '7f045eee-7003-4379-9968-9355ed2adb06@apps_vw-dilab_com';
+const REDIRECT_URI = 'myskoda://redirect/login/';
+const IDENT_BASE = 'https://identity.vwgroup.io';
+const API_BASE = 'https://mysmob.api.connect.skoda-auto.cz';
+const SCOPES = 'address badge birthdate cars driversLicense dealers email mileage mbb nationalIdentifier openid phone profession profile vin';
+const FORM_HEADERS = { 'content-type': 'application/x-www-form-urlencoded', accept: 'text/html' };
+
+class SkodaAuthError extends Error {
+ constructor(message, code) { super(message); this.name = 'SkodaAuthError'; this.code = code; }
+}
+
+function generatePkce() {
+ const verifier = crypto.randomBytes(32).toString('base64url');
+ const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
+ return { verifier, challenge };
+}
+
+function parseIdk(html) {
+ const csrf = html.match(/csrf_token:\s*['"]([^'"]+)['"]/);
+ const tpl = html.match(/templateModel:\s*(\{.*?\})\s*,?\s*\n/s);
+ if (!csrf || !tpl) throw new SkodaAuthError('cannot parse identity page', 'SKODA_AUTH_FLOW_CHANGED');
+ let templateModel;
+ try { templateModel = JSON.parse(tpl[1]); } catch {
+ throw new SkodaAuthError('cannot parse templateModel', 'SKODA_AUTH_FLOW_CHANGED');
+ }
+ return { csrfToken: csrf[1], templateModel };
+}
+
+function parseFragment(location) {
+ const hash = location.split('#')[1] || '';
+ return Object.fromEntries(new URLSearchParams(hash));
+}
+
+function formBody(fields) { return new URLSearchParams(fields).toString(); }
+
+async function exchangeCode(code, verifier, fetchImpl) {
+ const res = await fetchImpl(`${API_BASE}/api/v1/authentication/exchange-authorization-code?tokenType=CONNECT`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
+ body: JSON.stringify({ code, redirectUri: REDIRECT_URI, verifier }),
+ });
+ if (res.status === 429) throw new SkodaAuthError('rate limited', 'SKODA_RATE_LIMITED');
+ if (res.status >= 400) throw new SkodaAuthError(`code exchange failed (${res.status})`, 'SKODA_LOGIN_FAILED');
+ const json = await res.json();
+ return { accessToken: json.accessToken, refreshToken: json.refreshToken, idToken: json.idToken };
+}
+
+async function login(email, password, { fetchImpl = fetch } = {}) {
+ const jar = new CookieJar();
+ const { verifier, challenge } = generatePkce();
+ const nonce = crypto.randomBytes(16).toString('base64url');
+
+ const authorizeUrl = `${IDENT_BASE}/oidc/v1/authorize?` + new URLSearchParams({
+ client_id: CLIENT_ID,
+ nonce,
+ redirect_uri: REDIRECT_URI,
+ response_type: 'code id_token',
+ scope: SCOPES,
+ code_challenge: challenge,
+ code_challenge_method: 'S256',
+ }).toString();
+
+ const start = await followRedirects(jar, authorizeUrl, { method: 'GET', headers: { accept: 'text/html' } }, { fetchImpl });
+ const emailIdk = parseIdk(await start.res.text());
+
+ const identifierRes = await followRedirects(jar,
+ `${IDENT_BASE}/signin-service/v1/${CLIENT_ID}/login/identifier`,
+ { method: 'POST', headers: FORM_HEADERS, body: formBody({
+ _csrf: emailIdk.csrfToken,
+ relayState: emailIdk.templateModel.relayState,
+ hmac: emailIdk.templateModel.hmac,
+ email,
+ }) }, { fetchImpl });
+ const pwIdk = parseIdk(await identifierRes.res.text());
+
+ const finish = await followRedirects(jar,
+ `${IDENT_BASE}/signin-service/v1/${CLIENT_ID}/login/authenticate`,
+ { method: 'POST', headers: FORM_HEADERS, body: formBody({
+ _csrf: pwIdk.csrfToken,
+ relayState: pwIdk.templateModel.relayState,
+ hmac: pwIdk.templateModel.hmac,
+ email,
+ password,
+ }) }, { stopPrefix: 'myskoda://', fetchImpl });
+
+ if (!finish.location.startsWith('myskoda://')) {
+ if (finish.location.includes('terms-and-conditions')) {
+ throw new SkodaAuthError('terms acceptance required in MySkoda app', 'SKODA_TERMS_REQUIRED');
+ }
+ throw new SkodaAuthError('login did not reach redirect (wrong credentials?)', 'SKODA_LOGIN_FAILED');
+ }
+ const { code } = parseFragment(finish.location);
+ if (!code) throw new SkodaAuthError('no code in redirect', 'SKODA_AUTH_FLOW_CHANGED');
+ return exchangeCode(code, verifier, fetchImpl);
+}
+
+async function refresh(refreshToken, { fetchImpl = fetch } = {}) {
+ const res = await fetchImpl(`${API_BASE}/api/v1/authentication/refresh-token?tokenType=CONNECT`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
+ body: JSON.stringify({ token: refreshToken }),
+ });
+ if (res.status === 429) throw new SkodaAuthError('rate limited', 'SKODA_RATE_LIMITED');
+ if (res.status >= 400) throw new SkodaAuthError(`refresh failed (${res.status})`, 'SKODA_LOGIN_FAILED');
+ const json = await res.json();
+ return { accessToken: json.accessToken, refreshToken: json.refreshToken, idToken: json.idToken };
+}
+
+module.exports = {
+ SkodaAuthError, generatePkce, parseIdk, parseFragment, login, refresh,
+ CLIENT_ID, REDIRECT_URI, IDENT_BASE, API_BASE,
+};
diff --git a/tests/fixtures/skoda/idk_login_page.js b/tests/fixtures/skoda/idk_login_page.js
new file mode 100644
index 00000000..94687c05
--- /dev/null
+++ b/tests/fixtures/skoda/idk_login_page.js
@@ -0,0 +1,19 @@
+'use strict';
+// Synthetic VW identity login page (structure per myskoda reference).
+// Replace with a redacted live capture after the Task-3 spike if it deviates.
+const emailPage = `
Login
+
+`;
+
+const passwordPage = emailPage
+ .replace('hmac-abc', 'hmac-def')
+ .replace('"postAction":"login/identifier"', '"postAction":"login/authenticate"');
+
+module.exports = { emailPage, passwordPage };
diff --git a/tests/skoda_auth.test.js b/tests/skoda_auth.test.js
new file mode 100644
index 00000000..eb51b64a
--- /dev/null
+++ b/tests/skoda_auth.test.js
@@ -0,0 +1,108 @@
+'use strict';
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const crypto = require('node:crypto');
+const auth = require('../src/services/skoda/skodaAuth');
+const { emailPage, passwordPage } = require('./fixtures/skoda/idk_login_page');
+
+test('generatePkce returns base64url verifier and matching S256 challenge', () => {
+ const { verifier, challenge } = auth.generatePkce();
+ assert.match(verifier, /^[A-Za-z0-9_-]{43}$/);
+ const expected = crypto.createHash('sha256').update(verifier).digest('base64url');
+ assert.equal(challenge, expected);
+});
+
+test('parseIdk extracts csrf token and template model', () => {
+ const idk = auth.parseIdk(emailPage);
+ assert.equal(idk.csrfToken, 'csrf-123');
+ assert.equal(idk.templateModel.hmac, 'hmac-abc');
+ assert.equal(idk.templateModel.relayState, 'relay-xyz');
+ assert.equal(idk.templateModel.postAction, 'login/identifier');
+});
+
+test('parseIdk throws SKODA_AUTH_FLOW_CHANGED on unexpected html', () => {
+ assert.throws(() => auth.parseIdk('maintenance'), (e) => e.code === 'SKODA_AUTH_FLOW_CHANGED');
+});
+
+test('parseFragment reads code from myskoda redirect', () => {
+ const params = auth.parseFragment('myskoda://redirect/login/#code=THECODE&token_type=bearer&id_token=IDT');
+ assert.equal(params.code, 'THECODE');
+ assert.equal(params.id_token, 'IDT');
+});
+
+function htmlRes(body) {
+ return { status: 200, headers: new Headers({ 'content-type': 'text/html' }), text: async () => body, json: async () => ({}) };
+}
+function redirectRes(location, setCookies = []) {
+ const h = new Headers({ location });
+ for (const c of setCookies) h.append('set-cookie', c);
+ return { status: 302, headers: h, text: async () => '', json: async () => ({}) };
+}
+function jsonRes(obj, status = 200) {
+ return { status, headers: new Headers({ 'content-type': 'application/json' }), json: async () => obj, text: async () => JSON.stringify(obj) };
+}
+
+test('login walks the full flow and exchanges the code', async () => {
+ const seen = [];
+ const fetchImpl = async (url, opts = {}) => {
+ seen.push({ url, method: opts.method || 'GET', body: opts.body });
+ if (url.startsWith(auth.IDENT_BASE + '/oidc/v1/authorize')) return htmlRes(emailPage);
+ if (url.includes('/login/identifier')) return htmlRes(passwordPage);
+ if (url.includes('/login/authenticate')) return redirectRes(auth.IDENT_BASE + '/oidc/v1/oauth/sso?x=1', ['SESSION=s1']);
+ if (url.includes('/oidc/v1/oauth/sso')) return redirectRes('myskoda://redirect/login/#code=THECODE&id_token=IDT');
+ if (url.startsWith(auth.API_BASE + '/api/v1/authentication/exchange-authorization-code')) {
+ return jsonRes({ accessToken: 'AT', refreshToken: 'RT', idToken: 'IDT' });
+ }
+ throw new Error('unexpected url ' + url);
+ };
+ const tokens = await auth.login('a@b.c', 'pw', { fetchImpl });
+ assert.deepEqual(tokens, { accessToken: 'AT', refreshToken: 'RT', idToken: 'IDT' });
+ const exchange = seen.find((s) => s.url.includes('exchange-authorization-code'));
+ const body = JSON.parse(exchange.body);
+ assert.equal(body.code, 'THECODE');
+ assert.equal(body.redirectUri, auth.REDIRECT_URI);
+ assert.ok(body.verifier);
+ const identifierPost = seen.find((s) => s.url.includes('/login/identifier'));
+ assert.match(identifierPost.body, /email=a%40b.c/);
+ assert.match(identifierPost.body, /hmac=hmac-abc/);
+ const authPost = seen.find((s) => s.url.includes('/login/authenticate'));
+ assert.match(authPost.body, /hmac=hmac-def/);
+ assert.match(authPost.body, /password=pw/);
+});
+
+test('login maps terms-and-conditions redirect to SKODA_TERMS_REQUIRED', async () => {
+ const fetchImpl = async (url, opts = {}) => {
+ if (url.startsWith(auth.IDENT_BASE + '/oidc/v1/authorize')) return htmlRes(emailPage);
+ if (url.includes('/login/identifier')) return htmlRes(passwordPage);
+ if (url.includes('/login/authenticate')) return redirectRes(auth.IDENT_BASE + '/signin-service/v1/terms-and-conditions?x=1');
+ return htmlRes('terms');
+ };
+ await assert.rejects(auth.login('a@b.c', 'pw', { fetchImpl }), (e) => e.code === 'SKODA_TERMS_REQUIRED');
+});
+
+test('login maps wrong password (re-rendered login page) to SKODA_LOGIN_FAILED', async () => {
+ const fetchImpl = async (url, opts = {}) => {
+ if (url.startsWith(auth.IDENT_BASE + '/oidc/v1/authorize')) return htmlRes(emailPage);
+ if (url.includes('/login/identifier')) return htmlRes(passwordPage);
+ if (url.includes('/login/authenticate')) return htmlRes(passwordPage); // no redirect => login failed
+ throw new Error('unexpected ' + url);
+ };
+ await assert.rejects(auth.login('a@b.c', 'wrong', { fetchImpl }), (e) => e.code === 'SKODA_LOGIN_FAILED');
+});
+
+test('refresh posts refresh token and returns new tokens', async () => {
+ let seenBody = null;
+ const fetchImpl = async (url, opts = {}) => {
+ assert.ok(url.startsWith(auth.API_BASE + '/api/v1/authentication/refresh-token'));
+ seenBody = JSON.parse(opts.body);
+ return jsonRes({ accessToken: 'AT2', refreshToken: 'RT2', idToken: 'IDT2' });
+ };
+ const tokens = await auth.refresh('RT1', { fetchImpl });
+ assert.equal(seenBody.token, 'RT1');
+ assert.equal(tokens.accessToken, 'AT2');
+});
+
+test('refresh maps 429 to SKODA_RATE_LIMITED', async () => {
+ const fetchImpl = async () => jsonRes({}, 429);
+ await assert.rejects(auth.refresh('RT1', { fetchImpl }), (e) => e.code === 'SKODA_RATE_LIMITED');
+});
From 35ce4edfd990888d8d773727967b165b5c1aa31c Mon Sep 17 00:00:00 2001
From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com>
Date: Wed, 22 Jul 2026 11:26:55 +0200
Subject: [PATCH 03/15] feat(skoda): live spike script for MySkoda auth +
endpoints
---
scripts/skoda-spike.js | 50 ++++++++++++++++++++++++++++++++++++++++++
1 file changed, 50 insertions(+)
create mode 100644 scripts/skoda-spike.js
diff --git a/scripts/skoda-spike.js b/scripts/skoda-spike.js
new file mode 100644
index 00000000..4f5429fc
--- /dev/null
+++ b/scripts/skoda-spike.js
@@ -0,0 +1,50 @@
+'use strict';
+
+// Live spike for the MySkoda API. Usage:
+// SKODA_EMAIL=... SKODA_PASSWORD=... node scripts/skoda-spike.js [vin]
+// Prints token acquisition, garage list and the raw JSON of every status
+// endpoint for the first (or given) VIN. Never writes to the DB.
+
+const auth = require('../src/services/skoda/skodaAuth');
+
+const EMAIL = process.env.SKODA_EMAIL;
+const PASSWORD = process.env.SKODA_PASSWORD;
+if (!EMAIL || !PASSWORD) {
+ console.error('set SKODA_EMAIL and SKODA_PASSWORD');
+ process.exit(1);
+}
+
+async function get(tokens, path) {
+ const res = await fetch(`${auth.API_BASE}${path}`, {
+ headers: { authorization: `Bearer ${tokens.accessToken}`, accept: 'application/json' },
+ });
+ const body = await res.text();
+ console.log(`\n=== GET ${path} -> ${res.status} ===`);
+ try { console.log(JSON.stringify(JSON.parse(body), null, 2)); } catch { console.log(body.slice(0, 2000)); }
+ return res;
+}
+
+(async () => {
+ console.log('logging in…');
+ const tokens = await auth.login(EMAIL, PASSWORD);
+ console.log('login OK, got tokens (access token length:', tokens.accessToken.length, ')');
+
+ console.log('testing refresh…');
+ const refreshed = await auth.refresh(tokens.refreshToken);
+ console.log('refresh OK');
+
+ const garageRes = await get(refreshed, '/api/v2/garage?connectivityGenerations=MOD1&connectivityGenerations=MOD2&connectivityGenerations=MOD3&connectivityGenerations=MOD4');
+ const garage = await garageRes.clone?.().json?.() ?? null;
+ const vin = process.argv[2] || (garage && garage.vehicles && garage.vehicles[0] && garage.vehicles[0].vin);
+ if (!vin) { console.error('no vin found — check garage output above'); process.exit(1); }
+
+ await get(refreshed, `/api/v2/garage/vehicles/${vin}`);
+ await get(refreshed, `/api/v2/vehicle-status/${vin}`);
+ await get(refreshed, `/api/v2/vehicle-status/${vin}/driving-range`);
+ await get(refreshed, `/api/v1/charging/${vin}`);
+ await get(refreshed, `/api/v2/air-conditioning/${vin}`);
+ await get(refreshed, `/api/v1/maps/positions?vin=${vin}`);
+ await get(refreshed, `/api/v1/vehicle-health-report/warning-lights/${vin}`);
+ await get(refreshed, `/api/v3/vehicle-maintenance/vehicles/${vin}`);
+ console.log('\nSPIKE COMPLETE');
+})().catch((err) => { console.error('SPIKE FAILED:', err.code || '', err.message); process.exit(1); });
From 3780a1ac2b5f551511fa29ad86ba2065029b558b Mon Sep 17 00:00:00 2001
From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com>
Date: Wed, 22 Jul 2026 11:30:10 +0200
Subject: [PATCH 04/15] fix(skoda): spike script parsed garage json instead of
cloning consumed response
---
scripts/skoda-spike.js | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/scripts/skoda-spike.js b/scripts/skoda-spike.js
index 4f5429fc..e258d08c 100644
--- a/scripts/skoda-spike.js
+++ b/scripts/skoda-spike.js
@@ -20,8 +20,9 @@ async function get(tokens, path) {
});
const body = await res.text();
console.log(`\n=== GET ${path} -> ${res.status} ===`);
- try { console.log(JSON.stringify(JSON.parse(body), null, 2)); } catch { console.log(body.slice(0, 2000)); }
- return res;
+ let json = null;
+ try { json = JSON.parse(body); console.log(JSON.stringify(json, null, 2)); } catch { console.log(body.slice(0, 2000)); }
+ return { res, json };
}
(async () => {
@@ -33,8 +34,7 @@ async function get(tokens, path) {
const refreshed = await auth.refresh(tokens.refreshToken);
console.log('refresh OK');
- const garageRes = await get(refreshed, '/api/v2/garage?connectivityGenerations=MOD1&connectivityGenerations=MOD2&connectivityGenerations=MOD3&connectivityGenerations=MOD4');
- const garage = await garageRes.clone?.().json?.() ?? null;
+ const garage = (await get(refreshed, '/api/v2/garage?connectivityGenerations=MOD1&connectivityGenerations=MOD2&connectivityGenerations=MOD3&connectivityGenerations=MOD4')).json;
const vin = process.argv[2] || (garage && garage.vehicles && garage.vehicles[0] && garage.vehicles[0].vin);
if (!vin) { console.error('no vin found — check garage output above'); process.exit(1); }
From 197299698ddbe2cb8b61cea258fff0cf270f6950 Mon Sep 17 00:00:00 2001
From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com>
Date: Wed, 22 Jul 2026 11:32:38 +0200
Subject: [PATCH 05/15] feat(skoda): migration V66 for accounts, vehicles,
owners
---
src/db/migrationList.js | 37 +++++++++++++++++++++++++++++++++++++
tests/skoda_schema.test.js | 32 ++++++++++++++++++++++++++++++++
2 files changed, 69 insertions(+)
create mode 100644 tests/skoda_schema.test.js
diff --git a/src/db/migrationList.js b/src/db/migrationList.js
index ff535213..5a6d95c2 100644
--- a/src/db/migrationList.js
+++ b/src/db/migrationList.js
@@ -1176,6 +1176,43 @@ const migrations = [
sql: `ALTER TABLE smarthome_resources ADD COLUMN state_json TEXT;`,
detect: (db) => hasColumn(db, 'smarthome_resources', 'state_json'),
},
+ {
+ version: 66,
+ name: 'skoda_integration',
+ sql: `CREATE TABLE IF NOT EXISTS skoda_accounts (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ email TEXT NOT NULL UNIQUE,
+ password_enc TEXT NOT NULL,
+ session_enc TEXT,
+ status TEXT NOT NULL DEFAULT 'ok',
+ status_detail TEXT,
+ backoff_min INTEGER NOT NULL DEFAULT 0,
+ next_retry_at TEXT,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+ );
+ CREATE TABLE IF NOT EXISTS skoda_vehicles (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ account_id INTEGER NOT NULL,
+ vin TEXT NOT NULL UNIQUE,
+ name TEXT,
+ model TEXT,
+ state_json TEXT,
+ image BLOB,
+ image_url TEXT,
+ fetched_at TEXT,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+ );
+ CREATE INDEX IF NOT EXISTS idx_skoda_vehicles_account ON skoda_vehicles(account_id);
+ CREATE TABLE IF NOT EXISTS skoda_vehicle_owners (
+ skoda_vehicle_id INTEGER NOT NULL,
+ user_id INTEGER NOT NULL,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ PRIMARY KEY (skoda_vehicle_id, user_id)
+ );
+ CREATE INDEX IF NOT EXISTS idx_skoda_owners_user ON skoda_vehicle_owners(user_id);`,
+ detect: (db) => tableExists(db, 'skoda_accounts'),
+ },
];
module.exports = { migrations };
diff --git a/tests/skoda_schema.test.js b/tests/skoda_schema.test.js
new file mode 100644
index 00000000..b01d0a75
--- /dev/null
+++ b/tests/skoda_schema.test.js
@@ -0,0 +1,32 @@
+'use strict';
+const { test, before, after } = require('node:test');
+const assert = require('node:assert/strict');
+const nodeCrypto = require('node:crypto');
+process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || nodeCrypto.randomBytes(32).toString('hex');
+const { setup, teardown } = require('./helpers/setup');
+let getDb;
+
+before(async () => { await setup(); ({ getDb } = require('../src/db/connection')); });
+after(async () => { await teardown(); });
+
+test('skoda tables exist with expected columns', () => {
+ const db = getDb();
+ const cols = (t) => db.prepare(`PRAGMA table_info(${t})`).all().map((c) => c.name);
+ assert.deepEqual(
+ cols('skoda_accounts').sort(),
+ ['backoff_min', 'created_at', 'email', 'id', 'next_retry_at', 'password_enc', 'session_enc', 'status', 'status_detail', 'updated_at']
+ );
+ assert.deepEqual(
+ cols('skoda_vehicles').sort(),
+ ['account_id', 'created_at', 'fetched_at', 'id', 'image', 'image_url', 'model', 'name', 'state_json', 'vin']
+ );
+ assert.deepEqual(cols('skoda_vehicle_owners').sort(), ['created_at', 'skoda_vehicle_id', 'user_id']);
+});
+
+test('vin is unique', () => {
+ const db = getDb();
+ db.prepare("INSERT INTO skoda_accounts (email, password_enc) VALUES ('u@x.y', 'enc')").run();
+ const acc = db.prepare("SELECT id FROM skoda_accounts WHERE email = 'u@x.y'").get();
+ db.prepare("INSERT INTO skoda_vehicles (account_id, vin) VALUES (?, 'TMB1')").run(acc.id);
+ assert.throws(() => db.prepare("INSERT INTO skoda_vehicles (account_id, vin) VALUES (?, 'TMB1')").run(acc.id));
+});
From ae88cc0fc2a37a8969908d206e4c404fcb9d9051 Mon Sep 17 00:00:00 2001
From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com>
Date: Wed, 22 Jul 2026 11:37:10 +0200
Subject: [PATCH 06/15] feat(skoda): account CRUD with encrypted-at-rest
secrets
---
src/services/skoda/skodaAccounts.js | 70 +++++++++++++++++++++++++++
tests/skoda_accounts.test.js | 75 +++++++++++++++++++++++++++++
2 files changed, 145 insertions(+)
create mode 100644 src/services/skoda/skodaAccounts.js
create mode 100644 tests/skoda_accounts.test.js
diff --git a/src/services/skoda/skodaAccounts.js b/src/services/skoda/skodaAccounts.js
new file mode 100644
index 00000000..aa87ecaa
--- /dev/null
+++ b/src/services/skoda/skodaAccounts.js
@@ -0,0 +1,70 @@
+'use strict';
+
+const { getDb } = require('../../db/connection');
+const { encrypt, decrypt } = require('../../utils/crypto');
+
+function err(message, code) { const e = new Error(message); e.code = code; return e; }
+
+function createAccount({ email, password }) {
+ if (!email || typeof email !== 'string' || !/.+@.+/.test(email.trim())) throw err('valid email required', 'SKODA_VALIDATION');
+ if (!password || typeof password !== 'string') throw err('password required', 'SKODA_VALIDATION');
+ const db = getDb();
+ try {
+ const info = db.prepare('INSERT INTO skoda_accounts (email, password_enc) VALUES (?, ?)')
+ .run(email.trim(), encrypt(password));
+ return listAccounts().find((a) => a.id === info.lastInsertRowid);
+ } catch (e) {
+ if (/UNIQUE/.test(e.message)) throw err('account already exists', 'SKODA_ACCOUNT_EXISTS');
+ throw e;
+ }
+}
+
+function listAccounts() {
+ return getDb().prepare('SELECT id, email, status, status_detail, next_retry_at, updated_at, password_enc FROM skoda_accounts ORDER BY id').all()
+ .map((r) => ({
+ id: r.id, email: r.email, status: r.status, status_detail: r.status_detail,
+ next_retry_at: r.next_retry_at, updated_at: r.updated_at,
+ has_credentials: Boolean(r.password_enc),
+ }));
+}
+
+function getAccountWithSecrets(id) {
+ const r = getDb().prepare('SELECT * FROM skoda_accounts WHERE id = ?').get(id);
+ if (!r) return null;
+ return {
+ id: r.id, email: r.email, status: r.status, backoff_min: r.backoff_min, next_retry_at: r.next_retry_at,
+ password: decrypt(r.password_enc),
+ session: r.session_enc ? JSON.parse(decrypt(r.session_enc)) : null,
+ };
+}
+
+function updatePassword(id, password) {
+ if (!password || typeof password !== 'string') throw err('password required', 'SKODA_VALIDATION');
+ const info = getDb().prepare(`UPDATE skoda_accounts SET password_enc = ?, status = 'ok', status_detail = NULL,
+ backoff_min = 0, next_retry_at = NULL, updated_at = datetime('now') WHERE id = ?`).run(encrypt(password), id);
+ if (!info.changes) throw err('account not found', 'SKODA_ACCOUNT_NOT_FOUND');
+}
+
+function saveSession(id, sessionObj) {
+ getDb().prepare("UPDATE skoda_accounts SET session_enc = ?, updated_at = datetime('now') WHERE id = ?")
+ .run(sessionObj ? encrypt(JSON.stringify(sessionObj)) : null, id);
+}
+
+function setStatus(id, status, detail = null, { backoffMin = null, nextRetryAt = null } = {}) {
+ if (detail != null) detail = String(detail).slice(0, 300); // keep upstream error blobs out of the UI
+ getDb().prepare(`UPDATE skoda_accounts SET status = ?, status_detail = ?,
+ backoff_min = COALESCE(?, backoff_min), next_retry_at = ?, updated_at = datetime('now') WHERE id = ?`)
+ .run(status, detail, backoffMin, nextRetryAt, id);
+}
+
+function removeAccount(id) {
+ const db = getDb();
+ const tx = db.transaction((accountId) => {
+ db.prepare('DELETE FROM skoda_vehicle_owners WHERE skoda_vehicle_id IN (SELECT id FROM skoda_vehicles WHERE account_id = ?)').run(accountId);
+ db.prepare('DELETE FROM skoda_vehicles WHERE account_id = ?').run(accountId);
+ db.prepare('DELETE FROM skoda_accounts WHERE id = ?').run(accountId);
+ });
+ tx(id);
+}
+
+module.exports = { createAccount, listAccounts, getAccountWithSecrets, updatePassword, saveSession, setStatus, removeAccount };
diff --git a/tests/skoda_accounts.test.js b/tests/skoda_accounts.test.js
new file mode 100644
index 00000000..15813279
--- /dev/null
+++ b/tests/skoda_accounts.test.js
@@ -0,0 +1,75 @@
+'use strict';
+const { test, before, after, beforeEach } = require('node:test');
+const assert = require('node:assert/strict');
+const nodeCrypto = require('node:crypto');
+process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || nodeCrypto.randomBytes(32).toString('hex');
+const { setup, teardown } = require('./helpers/setup');
+let accounts; let getDb;
+
+before(async () => {
+ await setup();
+ accounts = require('../src/services/skoda/skodaAccounts');
+ ({ getDb } = require('../src/db/connection'));
+});
+after(async () => { await teardown(); });
+beforeEach(() => { for (const a of accounts.listAccounts()) accounts.removeAccount(a.id); });
+
+test('createAccount encrypts password at rest and redacts list', () => {
+ const acc = accounts.createAccount({ email: 'me@example.com', password: 'geheim' });
+ assert.equal(acc.email, 'me@example.com');
+ assert.equal(acc.has_credentials, true);
+ assert.equal('password' in acc, false);
+ const row = getDb().prepare('SELECT password_enc FROM skoda_accounts WHERE id = ?').get(acc.id);
+ assert.notEqual(row.password_enc, 'geheim');
+ assert.match(row.password_enc, /^[0-9a-f]{24}:[0-9a-f]{32}:/); // iv:tag:enc format
+});
+
+test('duplicate email raises SKODA_ACCOUNT_EXISTS', () => {
+ accounts.createAccount({ email: 'a@b.c', password: 'x' });
+ assert.throws(() => accounts.createAccount({ email: 'a@b.c', password: 'y' }), (e) => e.code === 'SKODA_ACCOUNT_EXISTS');
+});
+
+test('empty or malformed fields raise SKODA_VALIDATION', () => {
+ assert.throws(() => accounts.createAccount({ email: '', password: 'x' }), (e) => e.code === 'SKODA_VALIDATION');
+ assert.throws(() => accounts.createAccount({ email: 'keine-mail', password: 'x' }), (e) => e.code === 'SKODA_VALIDATION');
+ assert.throws(() => accounts.createAccount({ email: 'a@b.c', password: '' }), (e) => e.code === 'SKODA_VALIDATION');
+});
+
+test('setStatus caps status_detail length at 300 chars', () => {
+ const acc = accounts.createAccount({ email: 'cap@x.y', password: 'pw' });
+ accounts.setStatus(acc.id, 'error', 'x'.repeat(1000));
+ assert.equal(accounts.listAccounts().find((a) => a.id === acc.id).status_detail.length, 300);
+});
+
+test('getAccountWithSecrets decrypts password and session roundtrip', () => {
+ const acc = accounts.createAccount({ email: 'a@b.c', password: 'pw1' });
+ accounts.saveSession(acc.id, { accessToken: 'AT', refreshToken: 'RT' });
+ const full = accounts.getAccountWithSecrets(acc.id);
+ assert.equal(full.password, 'pw1');
+ assert.deepEqual(full.session, { accessToken: 'AT', refreshToken: 'RT' });
+});
+
+test('setStatus stores backoff and retry time; updatePassword resets them', () => {
+ const acc = accounts.createAccount({ email: 'a@b.c', password: 'pw1' });
+ accounts.setStatus(acc.id, 'rate_limited', 'HTTP 429', { backoffMin: 60, nextRetryAt: '2026-07-22T12:00:00Z' });
+ let listed = accounts.listAccounts()[0];
+ assert.equal(listed.status, 'rate_limited');
+ assert.equal(listed.next_retry_at, '2026-07-22T12:00:00Z');
+ accounts.updatePassword(acc.id, 'pw2');
+ listed = accounts.listAccounts()[0];
+ assert.equal(listed.status, 'ok');
+ assert.equal(listed.next_retry_at, null);
+ assert.equal(accounts.getAccountWithSecrets(acc.id).password, 'pw2');
+});
+
+test('removeAccount cascades vehicles and owners', () => {
+ const acc = accounts.createAccount({ email: 'a@b.c', password: 'pw' });
+ const db = getDb();
+ db.prepare('INSERT INTO skoda_vehicles (account_id, vin) VALUES (?, ?)').run(acc.id, 'TMBX');
+ const veh = db.prepare('SELECT id FROM skoda_vehicles WHERE vin = ?').get('TMBX');
+ const admin = db.prepare("SELECT id FROM users WHERE role = 'admin'").get();
+ db.prepare('INSERT INTO skoda_vehicle_owners (skoda_vehicle_id, user_id) VALUES (?, ?)').run(veh.id, admin.id);
+ accounts.removeAccount(acc.id);
+ assert.equal(db.prepare('SELECT COUNT(*) c FROM skoda_vehicles').get().c, 0);
+ assert.equal(db.prepare('SELECT COUNT(*) c FROM skoda_vehicle_owners').get().c, 0);
+});
From 5d6155e798c2c1b2755ca294a7aaa1e2ca937efa Mon Sep 17 00:00:00 2001
From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com>
Date: Wed, 22 Jul 2026 11:41:39 +0200
Subject: [PATCH 07/15] feat(skoda): REST client with token refresh + state
normalization
---
src/services/skoda/skodaClient.js | 138 ++++++++++++++++++++++++++
tests/fixtures/skoda/api_responses.js | 47 +++++++++
tests/skoda_client.test.js | 113 +++++++++++++++++++++
3 files changed, 298 insertions(+)
create mode 100644 src/services/skoda/skodaClient.js
create mode 100644 tests/fixtures/skoda/api_responses.js
create mode 100644 tests/skoda_client.test.js
diff --git a/src/services/skoda/skodaClient.js b/src/services/skoda/skodaClient.js
new file mode 100644
index 00000000..95a654a7
--- /dev/null
+++ b/src/services/skoda/skodaClient.js
@@ -0,0 +1,138 @@
+'use strict';
+
+const skodaAuth = require('./skodaAuth');
+const { API_BASE } = skodaAuth;
+
+class SkodaApiError extends Error {
+ constructor(message, code, status) { super(message); this.name = 'SkodaApiError'; this.code = code; this.status = status; }
+}
+
+// Hosts observed serving compositeRenders images; confirm/extend via Task-3 spike.
+const RENDER_HOST_ALLOWLIST = [/\.azureedge\.net$/, /\.skoda-auto\.cz$/];
+
+class SkodaClient {
+ constructor({ getSession, saveSession, fetchImpl = fetch }) {
+ this.getSession = getSession;
+ this.saveSession = saveSession;
+ this.fetchImpl = fetchImpl;
+ }
+
+ async _get(path, { retried = false } = {}) {
+ const session = this.getSession();
+ const res = await this.fetchImpl(`${API_BASE}${path}`, {
+ headers: { authorization: `Bearer ${session.accessToken}`, accept: 'application/json' },
+ });
+ if (res.status === 401 && !retried) {
+ const tokens = await skodaAuth.refresh(session.refreshToken, { fetchImpl: this.fetchImpl });
+ this.saveSession(tokens);
+ return this._get(path, { retried: true });
+ }
+ if (res.status === 401) throw new SkodaApiError('unauthorized', 'SKODA_UNAUTHORIZED', 401);
+ if (res.status === 429) throw new SkodaApiError('rate limited', 'SKODA_RATE_LIMITED', 429);
+ if (res.status >= 400) throw new SkodaApiError(`api error ${res.status} for ${path}`, 'SKODA_API_ERROR', res.status);
+ return res.json();
+ }
+
+ garage() { return this._get('/api/v2/garage?connectivityGenerations=MOD1&connectivityGenerations=MOD2&connectivityGenerations=MOD3&connectivityGenerations=MOD4'); }
+ vehicleInfo(vin) { return this._get(`/api/v2/garage/vehicles/${vin}`); }
+ vehicleStatus(vin) { return this._get(`/api/v2/vehicle-status/${vin}`); }
+ drivingRange(vin) { return this._get(`/api/v2/vehicle-status/${vin}/driving-range`); }
+ charging(vin) { return this._get(`/api/v1/charging/${vin}`); }
+ airConditioning(vin) { return this._get(`/api/v2/air-conditioning/${vin}`); }
+ position(vin) { return this._get(`/api/v1/maps/positions?vin=${vin}`); }
+ health(vin) { return this._get(`/api/v1/vehicle-health-report/warning-lights/${vin}`); }
+ maintenance(vin) { return this._get(`/api/v3/vehicle-maintenance/vehicles/${vin}`); }
+
+ async renderImage(url) {
+ // The url comes from the Skoda API response — never fetch it unvalidated,
+ // and never send our bearer token to an arbitrary host (SSRF/token leak).
+ // Confirm/extend the allowlist from the Task-3 live spike; if the spike
+ // shows the CDN serves images unauthenticated, drop the auth header here.
+ let parsed;
+ try { parsed = new URL(url); } catch { throw new SkodaApiError('invalid render url', 'SKODA_API_ERROR', 0); }
+ if (parsed.protocol !== 'https:' || !RENDER_HOST_ALLOWLIST.some((re) => re.test(parsed.hostname))) {
+ throw new SkodaApiError(`render url host not allowed: ${parsed.hostname}`, 'SKODA_API_ERROR', 0);
+ }
+ const session = this.getSession();
+ const res = await this.fetchImpl(parsed.toString(), { headers: { authorization: `Bearer ${session.accessToken}` } });
+ if (res.status >= 400) throw new SkodaApiError(`image fetch failed ${res.status}`, 'SKODA_API_ERROR', res.status);
+ return Buffer.from(await res.arrayBuffer());
+ }
+
+ async fetchFullState(vin) {
+ const parts = {};
+ const jobs = {
+ status: () => this.vehicleStatus(vin),
+ drivingRange: () => this.drivingRange(vin),
+ charging: () => this.charging(vin),
+ airConditioning: () => this.airConditioning(vin),
+ position: () => this.position(vin),
+ health: () => this.health(vin),
+ maintenance: () => this.maintenance(vin),
+ };
+ for (const [key, job] of Object.entries(jobs)) {
+ try { parts[key] = await job(); } catch (e) {
+ if (e.code === 'SKODA_RATE_LIMITED' || e.code === 'SKODA_UNAUTHORIZED') throw e; // account-level, abort
+ parts[key] = null;
+ }
+ }
+ return { parts, state: normalizeVehicleState(parts) };
+ }
+}
+
+const YES = (v) => (v == null ? null : String(v).toUpperCase() === 'YES');
+const OPEN = (v) => (v == null ? null : String(v).toUpperCase() === 'OPEN');
+const ON = (v) => (v == null ? null : String(v).toUpperCase() === 'ON');
+const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
+
+function normalizeVehicleState({ status, drivingRange, charging, airConditioning, position, health, maintenance }) {
+ const chStatus = charging && charging.status;
+ const per = drivingRange && drivingRange.primaryEngineRange;
+ const pos = position && Array.isArray(position.positions)
+ ? position.positions.find((p) => p && p.type === 'VEHICLE') : null;
+ const windowHeating = airConditioning && airConditioning.windowHeatingState;
+ return {
+ capturedAt: (status && status.carCapturedTimestamp) || (health && health.capturedAt) || null,
+ locked: status ? YES(status.overall && status.overall.locked) : null,
+ doorsOpen: status ? OPEN(status.overall && status.overall.doors) : null,
+ windowsOpen: status ? OPEN(status.overall && status.overall.windows) : null,
+ detail: {
+ bonnet: (status && status.detail && status.detail.bonnet) || null,
+ trunk: (status && status.detail && status.detail.trunk) || null,
+ sunroof: (status && status.detail && status.detail.sunroof) || null,
+ },
+ lightsOn: status ? ON(status.overall && status.overall.lights) : null,
+ soc: per ? num(per.currentSoCInPercent)
+ : (chStatus && chStatus.battery ? num(chStatus.battery.stateOfChargeInPercent) : null),
+ rangeKm: drivingRange ? num(drivingRange.totalRangeInKm) : null,
+ charging: {
+ state: (chStatus && chStatus.state) || null,
+ powerKw: chStatus ? num(chStatus.chargePowerInKw) : null,
+ remainingMin: chStatus ? num(chStatus.remainingTimeToFullyChargedInMinutes) : null,
+ targetPercent: charging && charging.settings ? num(charging.settings.targetStateOfChargeInPercent) : null,
+ mode: (charging && charging.settings && charging.settings.chargingCareMode) || null,
+ cableConnected: charging && charging.plug && charging.plug.connectionState != null
+ ? String(charging.plug.connectionState).toUpperCase() === 'CONNECTED' : null,
+ },
+ climate: {
+ state: (airConditioning && airConditioning.state) || null,
+ targetC: airConditioning && airConditioning.targetTemperature ? num(airConditioning.targetTemperature.temperatureValue) : null,
+ remainingMin: airConditioning ? num(airConditioning.estimatedDateTimeToReachTargetTemperature) : null,
+ windowHeating: windowHeating ? (ON(windowHeating.front) || ON(windowHeating.rear)) : null,
+ },
+ position: pos && pos.gpsCoordinates
+ ? { lat: num(pos.gpsCoordinates.latitude), lon: num(pos.gpsCoordinates.longitude) } : null,
+ health: {
+ mileageKm: health ? num(health.mileageInKm) : null,
+ warnings: (health && Array.isArray(health.warningLights) ? health.warningLights : [])
+ .map((w) => (typeof w === 'string' ? w : (w && (w.category || w.type)) || 'UNKNOWN')),
+ },
+ maintenance: {
+ dueInDays: maintenance && maintenance.maintenanceReport ? num(maintenance.maintenanceReport.inspectionDueInDays) : null,
+ dueInKm: maintenance && maintenance.maintenanceReport ? num(maintenance.maintenanceReport.inspectionDueInKm) : null,
+ partner: (maintenance && maintenance.preferredServicePartner && maintenance.preferredServicePartner.name) || null,
+ },
+ };
+}
+
+module.exports = { SkodaClient, SkodaApiError, normalizeVehicleState };
diff --git a/tests/fixtures/skoda/api_responses.js b/tests/fixtures/skoda/api_responses.js
new file mode 100644
index 00000000..a238596a
--- /dev/null
+++ b/tests/fixtures/skoda/api_responses.js
@@ -0,0 +1,47 @@
+'use strict';
+// Shapes per the python-myskoda reference models. Replace with redacted live
+// captures after the Task-3 spike where they deviate.
+module.exports = {
+ garage: {
+ vehicles: [{
+ vin: 'TMBTESTVIN000001', name: 'Elroq', title: 'Škoda Elroq',
+ specification: { model: 'Elroq', modelYear: '2025' },
+ compositeRenders: [{ layers: [{ url: 'https://ip-modcwp.azureedge.net/render1.png', viewPoint: 'EXTERIOR_FRONT' }] }],
+ }],
+ },
+ status: {
+ carCapturedTimestamp: '2026-07-22T08:00:00Z',
+ overall: { locked: 'YES', doors: 'CLOSED', windows: 'CLOSED', lights: 'OFF' },
+ detail: { bonnet: 'CLOSED', trunk: 'CLOSED', sunroof: 'UNSUPPORTED' },
+ },
+ drivingRange: {
+ carType: 'ELECTRIC',
+ totalRangeInKm: 310,
+ primaryEngineRange: { engineType: 'ELECTRIC', currentSoCInPercent: 74, remainingRangeInKm: 310 },
+ },
+ charging: {
+ status: {
+ state: 'CHARGING',
+ chargePowerInKw: 10.5,
+ remainingTimeToFullyChargedInMinutes: 95,
+ battery: { stateOfChargeInPercent: 74, remainingCruisingRangeInMeters: 310000 },
+ },
+ settings: { targetStateOfChargeInPercent: 80, chargingCareMode: 'ACTIVATED' },
+ isVehicleInSavedLocation: false, plug: { connectionState: 'CONNECTED' },
+ },
+ airConditioning: {
+ state: 'OFF',
+ targetTemperature: { temperatureValue: 22, unitInCar: 'CELSIUS' },
+ estimatedDateTimeToReachTargetTemperature: null,
+ windowHeatingState: { front: 'OFF', rear: 'OFF' },
+ },
+ positions: {
+ positions: [{ type: 'VEHICLE', gpsCoordinates: { latitude: 51.0, longitude: 7.0 } }],
+ errors: [],
+ },
+ health: { capturedAt: '2026-07-22T08:00:00Z', mileageInKm: 5210, warningLights: [] },
+ maintenance: {
+ maintenanceReport: { inspectionDueInDays: 210, inspectionDueInKm: 24790 },
+ preferredServicePartner: { name: 'Autohaus Test GmbH' },
+ },
+};
diff --git a/tests/skoda_client.test.js b/tests/skoda_client.test.js
new file mode 100644
index 00000000..abbb9cc3
--- /dev/null
+++ b/tests/skoda_client.test.js
@@ -0,0 +1,113 @@
+'use strict';
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const fx = require('./fixtures/skoda/api_responses');
+const { SkodaClient, SkodaApiError, normalizeVehicleState } = require('../src/services/skoda/skodaClient');
+const { API_BASE } = require('../src/services/skoda/skodaAuth');
+
+function jsonRes(obj, status = 200) {
+ return { status, ok: status < 400, headers: new Headers({ 'content-type': 'application/json' }), json: async () => obj, arrayBuffer: async () => new ArrayBuffer(0) };
+}
+
+function makeClient(routes, { session = { accessToken: 'AT', refreshToken: 'RT' } } = {}) {
+ const saved = [];
+ const calls = [];
+ const fetchImpl = async (url, opts = {}) => {
+ calls.push({ url, auth: (opts.headers || {}).authorization });
+ for (const [match, resOrFn] of routes) {
+ if (url.includes(match)) return typeof resOrFn === 'function' ? resOrFn(url, opts) : resOrFn;
+ }
+ throw new Error('unexpected url ' + url);
+ };
+ const client = new SkodaClient({ getSession: () => session, saveSession: (s) => saved.push(s), fetchImpl });
+ return { client, saved, calls };
+}
+
+test('garage sends bearer token and returns json', async () => {
+ const { client, calls } = makeClient([['/api/v2/garage', jsonRes(fx.garage)]]);
+ const garage = await client.garage();
+ assert.equal(garage.vehicles[0].vin, 'TMBTESTVIN000001');
+ assert.equal(calls[0].auth, 'Bearer AT');
+});
+
+test('401 triggers exactly one refresh then retry', async () => {
+ let statusCalls = 0;
+ const { client, saved } = makeClient([
+ ['/api/v2/vehicle-status/TMBTESTVIN000001', () => (statusCalls++ === 0 ? jsonRes({}, 401) : jsonRes(fx.status))],
+ ['/api/v1/authentication/refresh-token', jsonRes({ accessToken: 'AT2', refreshToken: 'RT2', idToken: 'ID2' })],
+ ]);
+ const status = await client.vehicleStatus('TMBTESTVIN000001');
+ assert.equal(status.overall.locked, 'YES');
+ assert.equal(statusCalls, 2);
+ assert.equal(saved[0].accessToken, 'AT2'); // refreshed session persisted
+});
+
+test('second 401 after refresh raises SKODA_UNAUTHORIZED', async () => {
+ const { client } = makeClient([
+ ['/api/v2/vehicle-status/', jsonRes({}, 401)],
+ ['/api/v1/authentication/refresh-token', jsonRes({ accessToken: 'AT2', refreshToken: 'RT2', idToken: 'ID2' })],
+ ]);
+ await assert.rejects(client.vehicleStatus('X'), (e) => e.code === 'SKODA_UNAUTHORIZED');
+});
+
+test('429 raises SKODA_RATE_LIMITED', async () => {
+ const { client } = makeClient([['/api/v1/charging/', jsonRes({}, 429)]]);
+ await assert.rejects(client.charging('X'), (e) => e.code === 'SKODA_RATE_LIMITED');
+});
+
+test('normalizeVehicleState maps all fixture parts', () => {
+ const state = normalizeVehicleState({
+ status: fx.status, drivingRange: fx.drivingRange, charging: fx.charging,
+ airConditioning: fx.airConditioning, position: fx.positions, health: fx.health, maintenance: fx.maintenance,
+ });
+ assert.equal(state.locked, true);
+ assert.equal(state.doorsOpen, false);
+ assert.equal(state.lightsOn, false);
+ assert.equal(state.detail.trunk, 'CLOSED');
+ assert.equal(state.soc, 74);
+ assert.equal(state.rangeKm, 310);
+ assert.equal(state.charging.state, 'CHARGING');
+ assert.equal(state.charging.powerKw, 10.5);
+ assert.equal(state.charging.remainingMin, 95);
+ assert.equal(state.charging.targetPercent, 80);
+ assert.equal(state.charging.cableConnected, true);
+ assert.equal(state.climate.state, 'OFF');
+ assert.equal(state.climate.targetC, 22);
+ assert.equal(state.climate.windowHeating, false);
+ assert.equal(state.position.lat, 51.0);
+ assert.equal(state.health.mileageKm, 5210);
+ assert.equal(state.maintenance.dueInDays, 210);
+ assert.equal(state.maintenance.partner, 'Autohaus Test GmbH');
+ assert.equal(state.capturedAt, '2026-07-22T08:00:00Z');
+});
+
+test('renderImage rejects non-allowlisted or non-https hosts', async () => {
+ const { client } = makeClient([]);
+ await assert.rejects(client.renderImage('https://evil.example/x.png'), (e) => e.code === 'SKODA_API_ERROR');
+ await assert.rejects(client.renderImage('http://ip-modcwp.azureedge.net/x.png'), (e) => e.code === 'SKODA_API_ERROR');
+ await assert.rejects(client.renderImage('nicht-mal-eine-url'), (e) => e.code === 'SKODA_API_ERROR');
+});
+
+test('normalizeVehicleState tolerates missing parts with nulls', () => {
+ const state = normalizeVehicleState({ status: null, drivingRange: null, charging: null, airConditioning: null, position: null, health: null, maintenance: null });
+ assert.equal(state.locked, null);
+ assert.equal(state.soc, null);
+ assert.deepEqual(state.health.warnings, []);
+ assert.equal(state.position, null);
+});
+
+test('fetchFullState survives one failing endpoint', async () => {
+ const routes = [
+ ['/api/v2/vehicle-status/V/driving-range', jsonRes(fx.drivingRange)],
+ ['/api/v2/vehicle-status/V', jsonRes(fx.status)],
+ ['/api/v1/charging/V', jsonRes({}, 500)], // this one fails
+ ['/api/v2/air-conditioning/V', jsonRes(fx.airConditioning)],
+ ['/api/v1/maps/positions', jsonRes(fx.positions)],
+ ['/api/v1/vehicle-health-report/warning-lights/V', jsonRes(fx.health)],
+ ['/api/v3/vehicle-maintenance/vehicles/V', jsonRes(fx.maintenance)],
+ ];
+ const { client } = makeClient(routes);
+ const { state } = await client.fetchFullState('V');
+ assert.equal(state.soc, 74); // from drivingRange
+ assert.equal(state.charging.state, null); // failed part -> nulls
+});
From b9ba82fdf9c979c824b2032afcd4a7d37375adb3 Mon Sep 17 00:00:00 2001
From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com>
Date: Wed, 22 Jul 2026 11:48:50 +0200
Subject: [PATCH 08/15] fix(skoda): wrap refresh failure in api error contract,
derive climate remainingMin
---
src/services/skoda/skodaClient.js | 16 ++++++++++++++--
tests/skoda_client.test.js | 24 ++++++++++++++++++++++--
2 files changed, 36 insertions(+), 4 deletions(-)
diff --git a/src/services/skoda/skodaClient.js b/src/services/skoda/skodaClient.js
index 95a654a7..50961782 100644
--- a/src/services/skoda/skodaClient.js
+++ b/src/services/skoda/skodaClient.js
@@ -23,7 +23,13 @@ class SkodaClient {
headers: { authorization: `Bearer ${session.accessToken}`, accept: 'application/json' },
});
if (res.status === 401 && !retried) {
- const tokens = await skodaAuth.refresh(session.refreshToken, { fetchImpl: this.fetchImpl });
+ let tokens;
+ try {
+ tokens = await skodaAuth.refresh(session.refreshToken, { fetchImpl: this.fetchImpl });
+ } catch (e) {
+ if (e.code === 'SKODA_RATE_LIMITED') throw e; // account-level, abort as-is
+ throw new SkodaApiError('token refresh failed', 'SKODA_UNAUTHORIZED', 401);
+ }
this.saveSession(tokens);
return this._get(path, { retried: true });
}
@@ -117,7 +123,13 @@ function normalizeVehicleState({ status, drivingRange, charging, airConditioning
climate: {
state: (airConditioning && airConditioning.state) || null,
targetC: airConditioning && airConditioning.targetTemperature ? num(airConditioning.targetTemperature.temperatureValue) : null,
- remainingMin: airConditioning ? num(airConditioning.estimatedDateTimeToReachTargetTemperature) : null,
+ remainingMin: (() => {
+ if (!airConditioning) return null;
+ const direct = num(airConditioning.remainingTimeToReachTargetTemperatureInMinutes);
+ if (direct != null) return direct;
+ const ts = Date.parse(airConditioning.estimatedDateTimeToReachTargetTemperature || '');
+ return Number.isFinite(ts) ? Math.max(0, Math.round((ts - Date.now()) / 60000)) : null;
+ })(),
windowHeating: windowHeating ? (ON(windowHeating.front) || ON(windowHeating.rear)) : null,
},
position: pos && pos.gpsCoordinates
diff --git a/tests/skoda_client.test.js b/tests/skoda_client.test.js
index abbb9cc3..c0b1e8e5 100644
--- a/tests/skoda_client.test.js
+++ b/tests/skoda_client.test.js
@@ -19,7 +19,7 @@ function makeClient(routes, { session = { accessToken: 'AT', refreshToken: 'RT'
}
throw new Error('unexpected url ' + url);
};
- const client = new SkodaClient({ getSession: () => session, saveSession: (s) => saved.push(s), fetchImpl });
+ const client = new SkodaClient({ getSession: () => session, saveSession: (s) => { saved.push(s); session = s; }, fetchImpl });
return { client, saved, calls };
}
@@ -32,7 +32,7 @@ test('garage sends bearer token and returns json', async () => {
test('401 triggers exactly one refresh then retry', async () => {
let statusCalls = 0;
- const { client, saved } = makeClient([
+ const { client, saved, calls } = makeClient([
['/api/v2/vehicle-status/TMBTESTVIN000001', () => (statusCalls++ === 0 ? jsonRes({}, 401) : jsonRes(fx.status))],
['/api/v1/authentication/refresh-token', jsonRes({ accessToken: 'AT2', refreshToken: 'RT2', idToken: 'ID2' })],
]);
@@ -40,6 +40,7 @@ test('401 triggers exactly one refresh then retry', async () => {
assert.equal(status.overall.locked, 'YES');
assert.equal(statusCalls, 2);
assert.equal(saved[0].accessToken, 'AT2'); // refreshed session persisted
+ assert.equal(calls[calls.length - 1].auth, 'Bearer AT2'); // retried request uses refreshed token
});
test('second 401 after refresh raises SKODA_UNAUTHORIZED', async () => {
@@ -50,6 +51,14 @@ test('second 401 after refresh raises SKODA_UNAUTHORIZED', async () => {
await assert.rejects(client.vehicleStatus('X'), (e) => e.code === 'SKODA_UNAUTHORIZED');
});
+test('failing refresh maps to SKODA_UNAUTHORIZED (no foreign error class)', async () => {
+ const { client } = makeClient([
+ ['/api/v2/vehicle-status/', jsonRes({}, 401)],
+ ['/api/v1/authentication/refresh-token', jsonRes({}, 403)],
+ ]);
+ await assert.rejects(client.vehicleStatus('X'), (e) => e.code === 'SKODA_UNAUTHORIZED' && e.name === 'SkodaApiError');
+});
+
test('429 raises SKODA_RATE_LIMITED', async () => {
const { client } = makeClient([['/api/v1/charging/', jsonRes({}, 429)]]);
await assert.rejects(client.charging('X'), (e) => e.code === 'SKODA_RATE_LIMITED');
@@ -96,6 +105,17 @@ test('normalizeVehicleState tolerates missing parts with nulls', () => {
assert.equal(state.position, null);
});
+test('climate remainingMin derives from minutes field or estimated datetime', () => {
+ const base = { state: 'HEATING', targetTemperature: { temperatureValue: 22 } };
+ let state = normalizeVehicleState({ airConditioning: { ...base, remainingTimeToReachTargetTemperatureInMinutes: 12 } });
+ assert.equal(state.climate.remainingMin, 12);
+ const eta = new Date(Date.now() + 30 * 60000).toISOString();
+ state = normalizeVehicleState({ airConditioning: { ...base, estimatedDateTimeToReachTargetTemperature: eta } });
+ assert.ok(state.climate.remainingMin >= 29 && state.climate.remainingMin <= 31);
+ state = normalizeVehicleState({ airConditioning: base });
+ assert.equal(state.climate.remainingMin, null);
+});
+
test('fetchFullState survives one failing endpoint', async () => {
const routes = [
['/api/v2/vehicle-status/V/driving-range', jsonRes(fx.drivingRange)],
From 6d73412e6bc3e928023ab606d92e573e338e1ce4 Mon Sep 17 00:00:00 2001
From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com>
Date: Wed, 22 Jul 2026 11:51:36 +0200
Subject: [PATCH 09/15] feat(skoda): vehicle owner n:m mapping + user delete
cascade
---
src/services/skoda/skodaOwners.js | 47 +++++++++++++++++++++++++
src/services/users.js | 2 ++
tests/skoda_owners.test.js | 57 +++++++++++++++++++++++++++++++
3 files changed, 106 insertions(+)
create mode 100644 src/services/skoda/skodaOwners.js
create mode 100644 tests/skoda_owners.test.js
diff --git a/src/services/skoda/skodaOwners.js b/src/services/skoda/skodaOwners.js
new file mode 100644
index 00000000..d04117e4
--- /dev/null
+++ b/src/services/skoda/skodaOwners.js
@@ -0,0 +1,47 @@
+'use strict';
+
+const { getDb } = require('../../db/connection');
+
+function err(message, code) { const e = new Error(message); e.code = code; return e; }
+
+function setOwners(vehicleId, userIds) {
+ const db = getDb();
+ if (!db.prepare('SELECT id FROM skoda_vehicles WHERE id = ?').get(vehicleId)) {
+ throw err('vehicle not found', 'SKODA_VEHICLE_NOT_FOUND');
+ }
+ const ids = [...new Set((userIds || []).map(Number))];
+ for (const uid of ids) {
+ if (!db.prepare('SELECT id FROM users WHERE id = ?').get(uid)) {
+ throw err(`unknown user ${uid}`, 'SKODA_OWNER_UNKNOWN_USER');
+ }
+ }
+ db.transaction(() => {
+ db.prepare('DELETE FROM skoda_vehicle_owners WHERE skoda_vehicle_id = ?').run(vehicleId);
+ const ins = db.prepare('INSERT INTO skoda_vehicle_owners (skoda_vehicle_id, user_id) VALUES (?, ?)');
+ for (const uid of ids) ins.run(vehicleId, uid);
+ })();
+}
+
+function ownersOf(vehicleId) {
+ return getDb().prepare(`SELECT u.id, u.username FROM skoda_vehicle_owners o
+ JOIN users u ON u.id = o.user_id WHERE o.skoda_vehicle_id = ? ORDER BY u.username`).all(vehicleId);
+}
+
+function vehiclesOwnedBy(userId) {
+ return getDb().prepare('SELECT skoda_vehicle_id FROM skoda_vehicle_owners WHERE user_id = ?')
+ .all(userId).map((r) => r.skoda_vehicle_id);
+}
+
+function isOwner(vehicleId, userId) {
+ return Boolean(getDb().prepare('SELECT 1 FROM skoda_vehicle_owners WHERE skoda_vehicle_id = ? AND user_id = ?').get(vehicleId, userId));
+}
+
+function removeAllForVehicle(vehicleId) {
+ getDb().prepare('DELETE FROM skoda_vehicle_owners WHERE skoda_vehicle_id = ?').run(vehicleId);
+}
+
+function removeAllForUser(userId) {
+ getDb().prepare('DELETE FROM skoda_vehicle_owners WHERE user_id = ?').run(userId);
+}
+
+module.exports = { setOwners, ownersOf, vehiclesOwnedBy, isOwner, removeAllForVehicle, removeAllForUser };
diff --git a/src/services/users.js b/src/services/users.js
index 5cf9f2d0..f311cdc7 100644
--- a/src/services/users.js
+++ b/src/services/users.js
@@ -7,6 +7,7 @@ const logger = require('../utils/logger');
const argon2Options = require('../utils/argon2Options');
const mideaOwners = require('./midea/mideaOwners');
const smarthomeOwners = require('./smarthome/smarthomeOwners');
+const skodaOwners = require('./skoda/skodaOwners');
const NO_PASSWORD_SENTINEL = '!';
@@ -267,6 +268,7 @@ function remove(id) {
db.prepare('UPDATE peers SET user_id = NULL WHERE user_id = ?').run(id);
mideaOwners.removeAllForUser(id); // clear AC ownership (no own tx)
smarthomeOwners.removeAllForUser(id); // clear smarthome ownership (no own tx)
+ skodaOwners.removeAllForUser(id); // clear Skoda vehicle ownership (no own tx)
db.prepare('DELETE FROM users WHERE id = ?').run(id);
})();
diff --git a/tests/skoda_owners.test.js b/tests/skoda_owners.test.js
new file mode 100644
index 00000000..d3d4c10e
--- /dev/null
+++ b/tests/skoda_owners.test.js
@@ -0,0 +1,57 @@
+'use strict';
+const { test, before, after, beforeEach } = require('node:test');
+const assert = require('node:assert/strict');
+const nodeCrypto = require('node:crypto');
+process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || nodeCrypto.randomBytes(32).toString('hex');
+const { setup, teardown } = require('./helpers/setup');
+
+let owners, getDb, vehicleId, adminId, db;
+
+before(async () => {
+ await setup();
+ owners = require('../src/services/skoda/skodaOwners');
+ ({ getDb } = require('../src/db/connection'));
+ db = getDb();
+ db.prepare("INSERT INTO skoda_accounts (email, password_enc) VALUES ('o@x.y', 'enc')").run();
+ const acc = db.prepare("SELECT id FROM skoda_accounts WHERE email='o@x.y'").get();
+ db.prepare("INSERT INTO skoda_vehicles (account_id, vin) VALUES (?, 'TMBOWN')").run(acc.id);
+ vehicleId = db.prepare("SELECT id FROM skoda_vehicles WHERE vin='TMBOWN'").get().id;
+ adminId = db.prepare("SELECT id FROM users WHERE role='admin'").get().id;
+});
+
+after(async () => { await teardown(); });
+
+beforeEach(() => { owners.removeAllForVehicle(vehicleId); });
+
+test('setOwners replaces assignment and isOwner reflects it', () => {
+ owners.setOwners(vehicleId, [adminId]);
+ assert.equal(owners.isOwner(vehicleId, adminId), true);
+ assert.deepEqual(owners.vehiclesOwnedBy(adminId), [vehicleId]);
+ owners.setOwners(vehicleId, []);
+ assert.equal(owners.isOwner(vehicleId, adminId), false);
+});
+
+test('unknown user rejected before write', () => {
+ assert.throws(() => owners.setOwners(vehicleId, [999999]), (e) => e.code === 'SKODA_OWNER_UNKNOWN_USER');
+ assert.equal(owners.ownersOf(vehicleId).length, 0);
+});
+
+test('unknown vehicle rejected', () => {
+ assert.throws(() => owners.setOwners(999999, [adminId]), (e) => e.code === 'SKODA_VEHICLE_NOT_FOUND');
+});
+
+test('ownersOf returns id and username', () => {
+ owners.setOwners(vehicleId, [adminId]);
+ const list = owners.ownersOf(vehicleId);
+ assert.equal(list.length, 1);
+ assert.equal(list[0].id, adminId);
+ assert.ok(list[0].username);
+});
+
+test('deleting a user removes their skoda owner rows', async () => {
+ const users = require('../src/services/users');
+ const u = await users.create({ username: 'skoda-owner-tmp', password: 'pw12345678', role: 'user' });
+ owners.setOwners(vehicleId, [u.id]);
+ users.remove(u.id);
+ assert.deepEqual(owners.vehiclesOwnedBy(u.id), []);
+});
From b953dcc9f7758d2e15c66a0a1583b8f3ebb31376 Mon Sep 17 00:00:00 2001
From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com>
Date: Wed, 22 Jul 2026 12:06:26 +0200
Subject: [PATCH 10/15] feat(skoda): vehicle db layer + sync orchestrator with
locks, backoff, cooldown
---
src/server.js | 4 +
src/services/license.js | 1 +
src/services/skoda/index.js | 183 ++++++++++++++++++++++++++++
src/services/skoda/skodaVehicles.js | 53 ++++++++
tests/helpers/setup.js | 1 +
tests/skoda_sync.test.js | 155 +++++++++++++++++++++++
6 files changed, 397 insertions(+)
create mode 100644 src/services/skoda/index.js
create mode 100644 src/services/skoda/skodaVehicles.js
create mode 100644 tests/skoda_sync.test.js
diff --git a/src/server.js b/src/server.js
index e23f8f27..3f564d5b 100644
--- a/src/server.js
+++ b/src/server.js
@@ -139,6 +139,10 @@ async function start() {
try { require('./services/midea').startPolling(); }
catch (err) { logger.warn({ err: err.message }, 'midea start failed'); }
+ // Skoda Connect poll loop — best-effort; no-op without license or enrolled accounts.
+ try { require('./services/skoda').startPolling(); }
+ catch (err) { logger.warn({ err: err.message }, 'skoda start failed'); }
+
// Smart Home (deCONZ) poll loop — best-effort; no-op without license or gateways.
try { require('./services/smarthome').startPolling(); }
catch (err) { logger.warn({ err: err.message }, 'smarthome start failed'); }
diff --git a/src/services/license.js b/src/services/license.js
index 09dcfde6..41551d45 100644
--- a/src/services/license.js
+++ b/src/services/license.js
@@ -48,6 +48,7 @@ const COMMUNITY_FALLBACK = {
internal_dns: false,
pihole_integration: false,
midea_integration: false,
+ skoda_integration: false,
smarthome: false,
gateway_peers: 1,
gateway_http_targets: 3,
diff --git a/src/services/skoda/index.js b/src/services/skoda/index.js
new file mode 100644
index 00000000..22a871d4
--- /dev/null
+++ b/src/services/skoda/index.js
@@ -0,0 +1,183 @@
+'use strict';
+
+const skodaAuth = require('./skodaAuth');
+const { SkodaClient } = require('./skodaClient');
+const accounts = require('./skodaAccounts');
+const vehicles = require('./skodaVehicles');
+const owners = require('./skodaOwners');
+const settings = require('../settings');
+const license = require('../license');
+const logger = require('../../utils/logger');
+
+const FEATURE = 'skoda_integration';
+const REFRESH_COOLDOWN_MS = 5 * 60 * 1000;
+const BACKOFF_START_MIN = 60;
+const BACKOFF_CAP_MIN = 240;
+
+let pollTimer = null;
+let pollRunning = false;
+let lastSyncAt = null;
+// ponytail: both maps grow one entry per vehicle/account ever touched — fine
+// for a two-car household, add cleanup if the fleet ever grows.
+const refreshCooldown = new Map(); // vehicleId -> ts
+const accountLocks = new Map(); // accountId -> promise chain tail
+
+// Serializes poller, manual refresh and account removal per account —
+// mirrors midea's withDeviceLock. Prevents parallel syncs racing the
+// session refresh (single-use refresh tokens) and delete-during-sync.
+function withAccountLock(id, fn) {
+ const prev = accountLocks.get(id) || Promise.resolve();
+ const next = prev.then(fn, fn);
+ accountLocks.set(id, next.catch(() => {}));
+ return next;
+}
+
+function pollIntervalMs() {
+ return Math.max(5, Number(settings.get('skoda_poll_interval_min', '15')) || 15) * 60000;
+}
+
+function clientFor(account, fetchImpl) {
+ return new SkodaClient({
+ getSession: () => accounts.getAccountWithSecrets(account.id).session,
+ saveSession: (tokens) => accounts.saveSession(account.id, tokens),
+ fetchImpl: fetchImpl || fetch,
+ });
+}
+
+async function ensureSession(account, fetchImpl) {
+ if (account.session && account.session.accessToken) return;
+ const tokens = await skodaAuth.login(account.email, account.password, { fetchImpl: fetchImpl || fetch });
+ accounts.saveSession(account.id, tokens);
+}
+
+function firstRenderUrl(vehicleInfo) {
+ const renders = (vehicleInfo && vehicleInfo.compositeRenders) || [];
+ for (const render of renders) {
+ for (const layer of (render && render.layers) || []) {
+ if (layer && layer.url) return layer.url;
+ }
+ }
+ return null;
+}
+
+async function syncVehicle(client, accountId, garageEntry) {
+ const row = vehicles.upsertVehicle(accountId, garageEntry);
+ const { state } = await client.fetchFullState(garageEntry.vin);
+ vehicles.saveState(row.id, state);
+
+ // Render image: fetch once, refetch only when the url changes.
+ try {
+ const info = await client.vehicleInfo(garageEntry.vin);
+ const url = firstRenderUrl(info);
+ if (url && (!row.image || row.image_url !== url)) {
+ vehicles.saveImage(row.id, await client.renderImage(url), url);
+ }
+ } catch (e) {
+ logger.warn({ err: e.message, vin: garageEntry.vin }, 'skoda render image fetch failed');
+ }
+}
+
+function syncAccount(accountId, { fetchImpl } = {}) {
+ return withAccountLock(accountId, () => syncAccountLocked(accountId, { fetchImpl }));
+}
+
+async function syncAccountLocked(accountId, { fetchImpl } = {}) {
+ let account = null;
+ try {
+ // Inside try: a corrupt session_enc (decrypt/JSON.parse throw) must mark
+ // THIS account as broken, not blow up the whole syncAll loop.
+ account = accounts.getAccountWithSecrets(accountId);
+ if (!account) return { ok: false, error: 'not found' };
+ await ensureSession(account, fetchImpl);
+ const client = clientFor(account, fetchImpl);
+ const garage = await client.garage();
+ const entries = (garage && garage.vehicles) || [];
+ for (const entry of entries) await syncVehicle(client, accountId, entry);
+ accounts.setStatus(accountId, 'ok', null, { backoffMin: 0, nextRetryAt: null });
+ lastSyncAt = new Date().toISOString();
+ return { ok: true, vehicles: entries.length };
+ } catch (e) {
+ if (e.code === 'SKODA_RATE_LIMITED') {
+ const prev = (account && account.backoff_min) || 0;
+ const backoffMin = prev ? Math.min(prev * 2, BACKOFF_CAP_MIN) : BACKOFF_START_MIN;
+ const nextRetryAt = new Date(Date.now() + backoffMin * 60000).toISOString();
+ accounts.setStatus(accountId, 'rate_limited', 'HTTP 429', { backoffMin, nextRetryAt });
+ } else if (e.code === 'SKODA_LOGIN_FAILED' || e.code === 'SKODA_TERMS_REQUIRED' || e.code === 'SKODA_AUTH_FLOW_CHANGED') {
+ accounts.saveSession(accountId, null); // drop stale session, force fresh login after fix
+ accounts.setStatus(accountId, 'login_failed', `${e.code}: ${e.message}`);
+ } else if (e.code === 'SKODA_UNAUTHORIZED') {
+ // expired/invalid session: drop it and let the next tick re-login with the stored password
+ accounts.saveSession(accountId, null);
+ accounts.setStatus(accountId, 'error', `${e.code}: ${e.message}`);
+ } else {
+ accounts.setStatus(accountId, 'error', e.message);
+ }
+ logger.warn({ err: e.message, code: e.code, accountId }, 'skoda sync failed');
+ return { ok: false, error: e.message };
+ }
+}
+
+async function syncAll({ fetchImpl, ignoreRetryAt = false } = {}) {
+ for (const acc of accounts.listAccounts()) {
+ if (acc.status === 'login_failed') continue;
+ if (!ignoreRetryAt && acc.status === 'rate_limited' && acc.next_retry_at && new Date(acc.next_retry_at) > new Date()) continue;
+ await syncAccount(acc.id, { fetchImpl });
+ }
+}
+
+async function refreshVehicle(vehicleId, { fetchImpl } = {}) {
+ const last = refreshCooldown.get(vehicleId) || 0;
+ if (Date.now() - last < REFRESH_COOLDOWN_MS) {
+ const e = new Error('refresh cooldown active');
+ e.code = 'SKODA_REFRESH_COOLDOWN';
+ throw e;
+ }
+ const accountId = vehicles.accountIdOf(vehicleId);
+ if (!accountId) { const e = new Error('vehicle not found'); e.code = 'SKODA_VEHICLE_NOT_FOUND'; throw e; }
+ refreshCooldown.set(vehicleId, Date.now());
+ return syncAccount(accountId, { fetchImpl });
+}
+
+function removeAccount(accountId) {
+ // Wait for any in-flight sync of this account before deleting, otherwise the
+ // sync re-inserts vehicle rows for an account that no longer exists.
+ return withAccountLock(accountId, () => accounts.removeAccount(accountId));
+}
+
+function getStatus() {
+ const vehicleList = vehicles.listRedacted().map((v) => ({ ...v, owners: owners.ownersOf(v.id) }));
+ return { accounts: accounts.listAccounts(), vehicles: vehicleList, lastSyncAt };
+}
+
+function getVehicleImage(vehicleId) {
+ return vehicles.getImage(vehicleId);
+}
+
+function pollTick() {
+ if (!license.hasFeature(FEATURE)) return;
+ if (pollRunning) return; // skip tick while a previous run is still going
+ if (!accounts.listAccounts().length) return;
+ pollRunning = true;
+ syncAll()
+ .catch((e) => logger.warn({ err: e.message }, 'skoda poll failed'))
+ .finally(() => { pollRunning = false; });
+}
+
+function startPolling() {
+ if (pollTimer) return;
+ if (!license.hasFeature(FEATURE)) return;
+ pollTimer = setInterval(pollTick, pollIntervalMs());
+ pollTimer.unref();
+ pollTick();
+}
+
+function stopPolling() {
+ if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
+}
+
+function _resetForTest() { stopPolling(); refreshCooldown.clear(); accountLocks.clear(); pollRunning = false; lastSyncAt = null; }
+
+module.exports = {
+ syncAccount, syncAll, refreshVehicle, removeAccount, getStatus, getVehicleImage,
+ startPolling, stopPolling, pollTick, pollIntervalMs, _resetForTest,
+};
diff --git a/src/services/skoda/skodaVehicles.js b/src/services/skoda/skodaVehicles.js
new file mode 100644
index 00000000..19fe07da
--- /dev/null
+++ b/src/services/skoda/skodaVehicles.js
@@ -0,0 +1,53 @@
+'use strict';
+
+const { getDb } = require('../../db/connection');
+const logger = require('../../utils/logger');
+
+function upsertVehicle(accountId, garageEntry) {
+ const db = getDb();
+ const vin = garageEntry.vin;
+ const name = garageEntry.name || garageEntry.title || vin;
+ const model = (garageEntry.specification && garageEntry.specification.model) || null;
+ // account_id only on insert: with vehicle sharing the same VIN can appear in
+ // two account garages — first assignment wins, no flapping between accounts.
+ db.prepare(`INSERT INTO skoda_vehicles (account_id, vin, name, model) VALUES (?, ?, ?, ?)
+ ON CONFLICT(vin) DO UPDATE SET name = excluded.name, model = excluded.model`)
+ .run(accountId, vin, name, model);
+ return db.prepare('SELECT id, image, image_url FROM skoda_vehicles WHERE vin = ?').get(vin);
+}
+
+function saveState(vehicleId, state) {
+ getDb().prepare("UPDATE skoda_vehicles SET state_json = ?, fetched_at = datetime('now') WHERE id = ?")
+ .run(JSON.stringify(state), vehicleId);
+}
+
+function saveImage(vehicleId, image, url) {
+ getDb().prepare('UPDATE skoda_vehicles SET image = ?, image_url = ? WHERE id = ?').run(image, url, vehicleId);
+}
+
+function listRedacted() {
+ return getDb().prepare('SELECT id, account_id, vin, name, model, state_json, fetched_at, image IS NOT NULL AS has_image FROM skoda_vehicles ORDER BY id').all()
+ .map((r) => {
+ let state = null;
+ if (r.state_json) {
+ try { state = JSON.parse(r.state_json); } catch { logger.warn({ vin: r.vin }, 'skoda corrupt state_json'); }
+ }
+ return {
+ id: r.id, account_id: r.account_id, vin: r.vin, name: r.name, model: r.model,
+ state, fetched_at: r.fetched_at, has_image: Boolean(r.has_image),
+ };
+ });
+}
+
+function getImage(vehicleId) {
+ const row = getDb().prepare('SELECT image, vin FROM skoda_vehicles WHERE id = ?').get(vehicleId);
+ if (!row || !row.image) return null;
+ return { image: row.image, vin: row.vin };
+}
+
+function accountIdOf(vehicleId) {
+ const row = getDb().prepare('SELECT account_id FROM skoda_vehicles WHERE id = ?').get(vehicleId);
+ return row ? row.account_id : null;
+}
+
+module.exports = { upsertVehicle, saveState, saveImage, listRedacted, getImage, accountIdOf };
diff --git a/tests/helpers/setup.js b/tests/helpers/setup.js
index ddad3bac..63a353af 100644
--- a/tests/helpers/setup.js
+++ b/tests/helpers/setup.js
@@ -78,6 +78,7 @@ async function setup() {
share_links: true,
access_windows: true,
midea_integration: true,
+ skoda_integration: true,
});
app = createApp();
diff --git a/tests/skoda_sync.test.js b/tests/skoda_sync.test.js
new file mode 100644
index 00000000..8a689403
--- /dev/null
+++ b/tests/skoda_sync.test.js
@@ -0,0 +1,155 @@
+'use strict';
+const { test, before, after, beforeEach } = require('node:test');
+const assert = require('node:assert/strict');
+const nodeCrypto = require('node:crypto');
+process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || nodeCrypto.randomBytes(32).toString('hex');
+const { setup, teardown } = require('./helpers/setup');
+const fx = require('./fixtures/skoda/api_responses');
+let skoda; let accounts; let getDb;
+
+function jsonRes(obj, status = 200) {
+ return { status, ok: status < 400, headers: new Headers(), json: async () => obj, text: async () => JSON.stringify(obj), arrayBuffer: async () => new TextEncoder().encode('PNG').buffer };
+}
+
+// fetchImpl serving a full happy-path account: login already has a session, so
+// only API calls happen. Login-flow paths are exercised in skoda_auth tests.
+function apiFetch({ fail = {} } = {}) {
+ return async (url) => {
+ if (fail[Object.keys(fail).find((k) => url.includes(k))]) return jsonRes({}, fail[Object.keys(fail).find((k) => url.includes(k))]);
+ if (url.includes('/api/v2/garage/vehicles/')) return jsonRes(fx.garage.vehicles[0]);
+ if (url.includes('/api/v2/garage')) return jsonRes(fx.garage);
+ if (url.includes('/driving-range')) return jsonRes(fx.drivingRange);
+ if (url.includes('/api/v2/vehicle-status/')) return jsonRes(fx.status);
+ if (url.includes('/api/v1/charging/')) return jsonRes(fx.charging);
+ if (url.includes('/api/v2/air-conditioning/')) return jsonRes(fx.airConditioning);
+ if (url.includes('/api/v1/maps/positions')) return jsonRes(fx.positions);
+ if (url.includes('/warning-lights/')) return jsonRes(fx.health);
+ if (url.includes('/vehicle-maintenance/')) return jsonRes(fx.maintenance);
+ if (url.includes('render1.png')) return jsonRes({});
+ throw new Error('unexpected ' + url);
+ };
+}
+
+before(async () => {
+ await setup();
+ skoda = require('../src/services/skoda');
+ accounts = require('../src/services/skoda/skodaAccounts');
+ ({ getDb } = require('../src/db/connection'));
+});
+after(async () => { skoda.stopPolling(); await teardown(); });
+beforeEach(() => {
+ skoda._resetForTest();
+ for (const a of accounts.listAccounts()) accounts.removeAccount(a.id);
+});
+
+function seedAccountWithSession() {
+ const acc = accounts.createAccount({ email: 's@x.y', password: 'pw' });
+ accounts.saveSession(acc.id, { accessToken: 'AT', refreshToken: 'RT' });
+ return acc;
+}
+
+test('syncAccount upserts vehicle with normalized state and image', async () => {
+ const acc = seedAccountWithSession();
+ const result = await skoda.syncAccount(acc.id, { fetchImpl: apiFetch() });
+ assert.equal(result.ok, true);
+ assert.equal(result.vehicles, 1);
+ const status = skoda.getStatus();
+ const v = status.vehicles[0];
+ assert.equal(v.vin, 'TMBTESTVIN000001');
+ assert.equal(v.name, 'Elroq');
+ assert.equal(v.state.soc, 74);
+ assert.ok(v.fetched_at);
+ assert.equal(v.has_image, true);
+ assert.equal('image' in v, false); // blob not in redacted listing
+ const img = skoda.getVehicleImage(v.id);
+ assert.ok(Buffer.isBuffer(img.image));
+});
+
+test('sync twice does not duplicate vehicles', async () => {
+ const acc = seedAccountWithSession();
+ await skoda.syncAccount(acc.id, { fetchImpl: apiFetch() });
+ await skoda.syncAccount(acc.id, { fetchImpl: apiFetch() });
+ assert.equal(getDb().prepare('SELECT COUNT(*) c FROM skoda_vehicles').get().c, 1);
+});
+
+test('429 sets rate_limited with 60min backoff, doubling capped at 240', async () => {
+ const acc = seedAccountWithSession();
+ await skoda.syncAccount(acc.id, { fetchImpl: apiFetch({ fail: { '/api/v2/garage': 429 } }) });
+ let a = accounts.listAccounts()[0];
+ assert.equal(a.status, 'rate_limited');
+ assert.ok(a.next_retry_at);
+ assert.equal(accounts.getAccountWithSecrets(acc.id).backoff_min, 60);
+ await skoda.syncAll({ fetchImpl: apiFetch({ fail: { '/api/v2/garage': 429 } }), ignoreRetryAt: true });
+ assert.equal(accounts.getAccountWithSecrets(acc.id).backoff_min, 120);
+ await skoda.syncAll({ fetchImpl: apiFetch({ fail: { '/api/v2/garage': 429 } }), ignoreRetryAt: true });
+ await skoda.syncAll({ fetchImpl: apiFetch({ fail: { '/api/v2/garage': 429 } }), ignoreRetryAt: true });
+ assert.equal(accounts.getAccountWithSecrets(acc.id).backoff_min, 240); // capped
+});
+
+test('syncAll skips rate_limited account before next_retry_at and success resets backoff', async () => {
+ const acc = seedAccountWithSession();
+ accounts.setStatus(acc.id, 'rate_limited', '429', { backoffMin: 60, nextRetryAt: '2999-01-01T00:00:00Z' });
+ let called = false;
+ await skoda.syncAll({ fetchImpl: async (u) => { called = true; return apiFetch()(u); } });
+ assert.equal(called, false);
+ accounts.setStatus(acc.id, 'rate_limited', '429', { backoffMin: 60, nextRetryAt: '2000-01-01T00:00:00Z' });
+ await skoda.syncAll({ fetchImpl: apiFetch() });
+ const a = accounts.listAccounts()[0];
+ assert.equal(a.status, 'ok');
+ assert.equal(accounts.getAccountWithSecrets(acc.id).backoff_min, 0);
+});
+
+test('refreshVehicle enforces 5 minute cooldown', async () => {
+ const acc = seedAccountWithSession();
+ await skoda.syncAccount(acc.id, { fetchImpl: apiFetch() });
+ const v = skoda.getStatus().vehicles[0];
+ await skoda.refreshVehicle(v.id, { fetchImpl: apiFetch() });
+ await assert.rejects(skoda.refreshVehicle(v.id, { fetchImpl: apiFetch() }), (e) => e.code === 'SKODA_REFRESH_COOLDOWN');
+});
+
+test('concurrent syncs of the same account are serialized (account lock)', async () => {
+ const acc = seedAccountWithSession();
+ let inFlight = 0; let maxInFlight = 0;
+ const slowFetch = async (url) => {
+ inFlight += 1; maxInFlight = Math.max(maxInFlight, inFlight);
+ await new Promise((r) => setTimeout(r, 5));
+ inFlight -= 1;
+ return apiFetch()(url);
+ };
+ await Promise.all([
+ skoda.syncAccount(acc.id, { fetchImpl: slowFetch }),
+ skoda.syncAccount(acc.id, { fetchImpl: slowFetch }),
+ ]);
+ assert.equal(maxInFlight, 1);
+});
+
+test('vehicle missing from a later garage response keeps its data (spec rule)', async () => {
+ const acc = seedAccountWithSession();
+ await skoda.syncAccount(acc.id, { fetchImpl: apiFetch() });
+ const emptyGarage = async (url) => (
+ url.includes('/api/v2/garage') && !url.includes('/garage/vehicles/')
+ ? jsonRes({ vehicles: [] })
+ : apiFetch()(url)
+ );
+ await skoda.syncAccount(acc.id, { fetchImpl: emptyGarage });
+ const v = skoda.getStatus().vehicles[0];
+ assert.equal(v.vin, 'TMBTESTVIN000001');
+ assert.ok(v.state);
+ assert.ok(v.fetched_at);
+});
+
+test('corrupt session_enc marks account as error without breaking syncAll', async () => {
+ const acc = seedAccountWithSession();
+ getDb().prepare('UPDATE skoda_accounts SET session_enc = ? WHERE id = ?').run('kaputt', acc.id);
+ await skoda.syncAll({ fetchImpl: apiFetch() }); // must not throw
+ assert.equal(accounts.listAccounts()[0].status, 'error');
+});
+
+test('pollIntervalMs respects setting with a 5 minute floor', () => {
+ const settings = require('../src/services/settings');
+ settings.set('skoda_poll_interval_min', '30');
+ assert.equal(skoda.pollIntervalMs(), 30 * 60000);
+ settings.set('skoda_poll_interval_min', '1');
+ assert.equal(skoda.pollIntervalMs(), 5 * 60000);
+ settings.set('skoda_poll_interval_min', '15');
+});
From 86442a8f24c12932de6395f7d065bb7115989a79 Mon Sep 17 00:00:00 2001
From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com>
Date: Wed, 22 Jul 2026 12:10:41 +0200
Subject: [PATCH 11/15] feat(skoda): admin API for accounts, owners, refresh,
settings
---
src/routes/api/index.js | 1 +
src/routes/api/skoda.js | 98 +++++++++++++++++++++++++++++++
tests/skoda_api.test.js | 124 ++++++++++++++++++++++++++++++++++++++++
3 files changed, 223 insertions(+)
create mode 100644 src/routes/api/skoda.js
create mode 100644 tests/skoda_api.test.js
diff --git a/src/routes/api/index.js b/src/routes/api/index.js
index e6bbc209..bef849d9 100644
--- a/src/routes/api/index.js
+++ b/src/routes/api/index.js
@@ -49,6 +49,7 @@ router.use('/client', require('./client'));
router.use('/rdp', require('./rdp'));
router.use('/pihole', require('./pihole'));
router.use('/midea', require('./midea'));
+router.use('/skoda', require('./skoda'));
router.use('/smarthome', require('./smarthome'));
module.exports = router;
diff --git a/src/routes/api/skoda.js b/src/routes/api/skoda.js
new file mode 100644
index 00000000..24c6bdaf
--- /dev/null
+++ b/src/routes/api/skoda.js
@@ -0,0 +1,98 @@
+'use strict';
+
+const { Router } = require('express');
+const { requireFeature } = require('../../middleware/license');
+const users = require('../../services/users');
+const skoda = require('../../services/skoda');
+const accounts = require('../../services/skoda/skodaAccounts');
+const owners = require('../../services/skoda/skodaOwners');
+const settings = require('../../services/settings');
+
+const router = Router();
+
+router.use((req, res, next) => {
+ if (req.tokenAuth) return res.status(403).json({ ok: false, error: req.t('error.users.session_required') });
+ if (!req.session || !req.session.userId) return res.status(401).json({ ok: false, error: req.t('error.users.unauthorized') });
+ const user = users.getById(req.session.userId);
+ if (!user || user.role !== 'admin') return res.status(403).json({ ok: false, error: req.t('error.users.admin_required') });
+ next();
+});
+router.use(requireFeature('skoda_integration'));
+
+const STATUS_BY_CODE = {
+ SKODA_VALIDATION: 400,
+ SKODA_OWNER_UNKNOWN_USER: 400,
+ SKODA_ACCOUNT_EXISTS: 409,
+ SKODA_REFRESH_COOLDOWN: 429,
+ SKODA_RATE_LIMITED: 429,
+ SKODA_VEHICLE_NOT_FOUND: 404,
+ SKODA_ACCOUNT_NOT_FOUND: 404,
+};
+
+function wrap(fn) {
+ return async (req, res) => {
+ try { await fn(req, res); } catch (e) {
+ const status = STATUS_BY_CODE[e.code] || (/not found/i.test(e.message) ? 404 : 502);
+ res.status(status).json({ ok: false, error: e.message, code: e.code || null });
+ }
+ };
+}
+
+router.get('/', wrap(async (req, res) => {
+ res.json({ ok: true, ...skoda.getStatus(), poll_interval_min: skoda.pollIntervalMs() / 60000 });
+}));
+
+router.post('/accounts', wrap(async (req, res) => {
+ // No implicit sync here: the UI calls POST /accounts/:id/sync afterwards.
+ // Keeps unit tests free of real network login attempts.
+ const acc = accounts.createAccount({ email: req.body.email, password: req.body.password });
+ res.status(201).json({ ok: true, account: acc });
+}));
+
+router.post('/accounts/:id/sync', wrap(async (req, res) => {
+ const result = await skoda.syncAccount(Number(req.params.id));
+ res.json({ ok: true, result });
+}));
+
+router.put('/accounts/:id', wrap(async (req, res) => {
+ accounts.updatePassword(Number(req.params.id), req.body.password);
+ res.json({ ok: true });
+}));
+
+router.delete('/accounts/:id', wrap(async (req, res) => {
+ await skoda.removeAccount(Number(req.params.id)); // account lock: waits for in-flight sync
+ res.json({ ok: true });
+}));
+
+router.post('/vehicles/:id/refresh', wrap(async (req, res) => {
+ await skoda.refreshVehicle(Number(req.params.id));
+ res.json({ ok: true });
+}));
+
+router.put('/vehicles/:id/owners', wrap(async (req, res) => {
+ const rawIds = req.body && req.body.user_ids;
+ if (!Array.isArray(rawIds)) {
+ return res.status(400).json({ ok: false, error: 'user_ids must be an array', code: 'SKODA_VALIDATION' });
+ }
+ owners.setOwners(Number(req.params.id), rawIds);
+ res.json({ ok: true, owners: owners.ownersOf(Number(req.params.id)) });
+}));
+
+router.get('/vehicles/:id/image', wrap(async (req, res) => {
+ const img = skoda.getVehicleImage(Number(req.params.id));
+ if (!img) return res.status(404).json({ ok: false, error: 'no image', code: null });
+ res.set('content-type', 'image/png').set('cache-control', 'private, max-age=86400').send(img.image);
+}));
+
+router.put('/settings', wrap(async (req, res) => {
+ const val = Number(req.body.poll_interval_min);
+ if (!Number.isInteger(val) || val < 5 || val > 1440) {
+ return res.status(400).json({ ok: false, error: 'poll_interval_min must be 5..1440', code: 'SKODA_VALIDATION' });
+ }
+ settings.set('skoda_poll_interval_min', String(val));
+ skoda.stopPolling();
+ skoda.startPolling();
+ res.json({ ok: true });
+}));
+
+module.exports = router;
diff --git a/tests/skoda_api.test.js b/tests/skoda_api.test.js
new file mode 100644
index 00000000..3e6955f1
--- /dev/null
+++ b/tests/skoda_api.test.js
@@ -0,0 +1,124 @@
+'use strict';
+const { test, before, after, beforeEach } = require('node:test');
+const assert = require('node:assert/strict');
+const nodeCrypto = require('node:crypto');
+process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || nodeCrypto.randomBytes(32).toString('hex');
+const { setup, teardown } = require('./helpers/setup');
+let ctx; let accounts; let skoda; let getDb;
+
+before(async () => {
+ ctx = await setup();
+ accounts = require('../src/services/skoda/skodaAccounts');
+ skoda = require('../src/services/skoda');
+ ({ getDb } = require('../src/db/connection'));
+});
+after(async () => { skoda.stopPolling(); await teardown(); });
+beforeEach(() => {
+ skoda._resetForTest();
+ for (const a of accounts.listAccounts()) accounts.removeAccount(a.id);
+});
+
+function seedVehicle() {
+ const acc = accounts.createAccount({ email: 'v@x.y', password: 'pw' });
+ const db = getDb();
+ db.prepare("INSERT INTO skoda_vehicles (account_id, vin, name, model, state_json, fetched_at, image, image_url) VALUES (?, 'TMBAPI', 'Elroq', 'Elroq', '{\"soc\":50}', datetime('now'), x'89504e47', 'u')").run(acc.id);
+ return db.prepare("SELECT id FROM skoda_vehicles WHERE vin='TMBAPI'").get().id;
+}
+
+test('GET /api/v1/skoda returns accounts, vehicles, poll interval', async () => {
+ seedVehicle();
+ const res = await ctx.agent.get('/api/v1/skoda');
+ assert.equal(res.status, 200);
+ assert.equal(res.body.ok, true);
+ assert.equal(res.body.accounts.length, 1);
+ assert.equal('password_enc' in res.body.accounts[0], false);
+ assert.equal(res.body.vehicles[0].vin, 'TMBAPI');
+ assert.equal(res.body.vehicles[0].state.soc, 50);
+ assert.equal(typeof res.body.poll_interval_min, 'number');
+});
+
+test('POST /accounts validates and creates', async () => {
+ let res = await ctx.agent.post('/api/v1/skoda/accounts').set('x-csrf-token', ctx.csrfToken).send({ email: '', password: 'x' });
+ assert.equal(res.status, 400);
+ res = await ctx.agent.post('/api/v1/skoda/accounts').set('x-csrf-token', ctx.csrfToken).send({ email: 'n@x.y', password: 'pw' });
+ assert.equal(res.status, 201);
+ assert.equal(res.body.ok, true);
+ res = await ctx.agent.post('/api/v1/skoda/accounts').set('x-csrf-token', ctx.csrfToken).send({ email: 'n@x.y', password: 'pw' });
+ assert.equal(res.status, 409);
+});
+
+test('owners PUT validates users and vehicle', async () => {
+ const vid = seedVehicle();
+ const admin = getDb().prepare("SELECT id FROM users WHERE role='admin'").get();
+ let res = await ctx.agent.put(`/api/v1/skoda/vehicles/${vid}/owners`).set('x-csrf-token', ctx.csrfToken).send({ user_ids: [admin.id] });
+ assert.equal(res.status, 200);
+ res = await ctx.agent.put(`/api/v1/skoda/vehicles/${vid}/owners`).set('x-csrf-token', ctx.csrfToken).send({ user_ids: [999999] });
+ assert.equal(res.status, 400);
+ res = await ctx.agent.put('/api/v1/skoda/vehicles/999999/owners').set('x-csrf-token', ctx.csrfToken).send({ user_ids: [] });
+ assert.equal(res.status, 404);
+});
+
+test('owners PUT rejects non-array user_ids with 400', async () => {
+ const vid = seedVehicle();
+ const res = await ctx.agent.put(`/api/v1/skoda/vehicles/${vid}/owners`).set('x-csrf-token', ctx.csrfToken).send({ user_ids: 'nope' });
+ assert.equal(res.status, 400);
+ assert.equal(res.body.code, 'SKODA_VALIDATION');
+});
+
+test('PUT /accounts/:id updates password, DELETE removes account', async () => {
+ const created = await ctx.agent.post('/api/v1/skoda/accounts').set('x-csrf-token', ctx.csrfToken).send({ email: 'p@x.y', password: 'pw' });
+ const id = created.body.account.id;
+ let res = await ctx.agent.put(`/api/v1/skoda/accounts/${id}`).set('x-csrf-token', ctx.csrfToken).send({ password: 'pw2' });
+ assert.equal(res.status, 200);
+ res = await ctx.agent.put('/api/v1/skoda/accounts/999999').set('x-csrf-token', ctx.csrfToken).send({ password: 'x' });
+ assert.equal(res.status, 404);
+ res = await ctx.agent.delete(`/api/v1/skoda/accounts/${id}`).set('x-csrf-token', ctx.csrfToken);
+ assert.equal(res.status, 200);
+ assert.equal(accounts.listAccounts().length, 0);
+});
+
+test('POST /accounts/:id/sync triggers a (mocked) sync', async () => {
+ const { mock } = require('node:test');
+ const acc = accounts.createAccount({ email: 'sync@x.y', password: 'pw' });
+ const m = mock.method(skoda, 'syncAccount', async () => ({ ok: true, vehicles: 0 }));
+ const res = await ctx.agent.post(`/api/v1/skoda/accounts/${acc.id}/sync`).set('x-csrf-token', ctx.csrfToken).send({});
+ assert.equal(res.status, 200);
+ assert.equal(m.mock.callCount(), 1);
+ m.mock.restore();
+});
+
+test('vehicle image served admin-only with content type', async () => {
+ const vid = seedVehicle();
+ const res = await ctx.agent.get(`/api/v1/skoda/vehicles/${vid}/image`);
+ assert.equal(res.status, 200);
+ assert.match(res.headers['content-type'], /image\/png/);
+ const missing = await ctx.agent.get('/api/v1/skoda/vehicles/999999/image');
+ assert.equal(missing.status, 404);
+});
+
+test('refresh cooldown maps to 429', async () => {
+ const { mock } = require('node:test');
+ const vid = seedVehicle();
+ // Mocked: the cooldown logic itself is covered in skoda_sync.test.js — here
+ // we only verify the error-code -> HTTP-status mapping, without any network.
+ const err = Object.assign(new Error('refresh cooldown active'), { code: 'SKODA_REFRESH_COOLDOWN' });
+ const m = mock.method(skoda, 'refreshVehicle', async () => { throw err; });
+ const res = await ctx.agent.post(`/api/v1/skoda/vehicles/${vid}/refresh`).set('x-csrf-token', ctx.csrfToken).send({});
+ assert.equal(res.status, 429);
+ assert.equal(res.body.code, 'SKODA_REFRESH_COOLDOWN');
+ m.mock.restore();
+});
+
+test('settings PUT validates range and persists', async () => {
+ let res = await ctx.agent.put('/api/v1/skoda/settings').set('x-csrf-token', ctx.csrfToken).send({ poll_interval_min: 3 });
+ assert.equal(res.status, 400);
+ res = await ctx.agent.put('/api/v1/skoda/settings').set('x-csrf-token', ctx.csrfToken).send({ poll_interval_min: 30 });
+ assert.equal(res.status, 200);
+ assert.equal(require('../src/services/settings').get('skoda_poll_interval_min'), '30');
+});
+
+test('unauthenticated request is rejected', async () => {
+ const supertest = require('supertest');
+ const res = await supertest(ctx.app).get('/api/v1/skoda');
+ assert.equal(res.status, 401);
+});
From 923f829f5fc3df3ca265fcee256197598e3529aa Mon Sep 17 00:00:00 2001
From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com>
Date: Wed, 22 Jul 2026 12:14:21 +0200
Subject: [PATCH 12/15] fix(skoda): assert and enforce secret redaction on
account creation response
---
src/routes/api/skoda.js | 3 ++-
tests/skoda_api.test.js | 4 ++++
2 files changed, 6 insertions(+), 1 deletion(-)
diff --git a/src/routes/api/skoda.js b/src/routes/api/skoda.js
index 24c6bdaf..0bf80628 100644
--- a/src/routes/api/skoda.js
+++ b/src/routes/api/skoda.js
@@ -46,7 +46,8 @@ router.post('/accounts', wrap(async (req, res) => {
// No implicit sync here: the UI calls POST /accounts/:id/sync afterwards.
// Keeps unit tests free of real network login attempts.
const acc = accounts.createAccount({ email: req.body.email, password: req.body.password });
- res.status(201).json({ ok: true, account: acc });
+ const { password, password_enc, session_enc, ...safe } = acc || {};
+ res.status(201).json({ ok: true, account: safe });
}));
router.post('/accounts/:id/sync', wrap(async (req, res) => {
diff --git a/tests/skoda_api.test.js b/tests/skoda_api.test.js
index 3e6955f1..46c1cf54 100644
--- a/tests/skoda_api.test.js
+++ b/tests/skoda_api.test.js
@@ -43,6 +43,10 @@ test('POST /accounts validates and creates', async () => {
res = await ctx.agent.post('/api/v1/skoda/accounts').set('x-csrf-token', ctx.csrfToken).send({ email: 'n@x.y', password: 'pw' });
assert.equal(res.status, 201);
assert.equal(res.body.ok, true);
+ assert.equal('password' in res.body.account, false);
+ assert.equal('password_enc' in res.body.account, false);
+ assert.equal('session_enc' in res.body.account, false);
+ assert.equal(res.body.account.has_credentials, true);
res = await ctx.agent.post('/api/v1/skoda/accounts').set('x-csrf-token', ctx.csrfToken).send({ email: 'n@x.y', password: 'pw' });
assert.equal(res.status, 409);
});
From 8db4936b22b21bb35c9aed56e0b6182ab12e79c3 Mon Sep 17 00:00:00 2001
From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com>
Date: Wed, 22 Jul 2026 12:29:29 +0200
Subject: [PATCH 13/15] feat(skoda): admin page /skoda in all three themes with
i18n
---
CHANGELOG.md | 5 +
public/css/skoda.css | 18 ++++
public/js/skoda.js | 130 +++++++++++++++++++++++++
src/i18n/de.json | 27 +++++
src/i18n/en.json | 27 +++++
src/routes/index.js | 1 +
templates/aurora/layout.njk | 31 +++++-
templates/aurora/pages/skoda.njk | 57 +++++++++++
templates/aurora/partials/sidebar.njk | 3 +
templates/default/layout.njk | 31 +++++-
templates/default/pages/skoda.njk | 57 +++++++++++
templates/default/partials/sidebar.njk | 6 ++
templates/pro/layout.njk | 31 +++++-
templates/pro/pages/skoda.njk | 57 +++++++++++
templates/pro/partials/sidebar.njk | 6 ++
tests/skoda_i18n.test.js | 33 +++++++
tests/skoda_page.test.js | 16 +++
17 files changed, 530 insertions(+), 6 deletions(-)
create mode 100644 public/css/skoda.css
create mode 100644 public/js/skoda.js
create mode 100644 templates/aurora/pages/skoda.njk
create mode 100644 templates/default/pages/skoda.njk
create mode 100644 templates/pro/pages/skoda.njk
create mode 100644 tests/skoda_i18n.test.js
create mode 100644 tests/skoda_page.test.js
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5f2b5d4e..6db6554e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -23,6 +23,11 @@
## [Unreleased]
+### Added
+- Skoda Connect integration (TP1): MySkoda account linking, vehicle sync with
+ state cache and rate-limit backoff, admin page `/skoda` with owner mapping
+ (feature flag `skoda_integration`).
+
### Changed
- Routes page (Aurora): flat table replaced by a card grid — group cards for service bundles/shared domains, slim single-route cards elsewhere; domainless L4 forwards get derived names (SSH, RDP, IPP, …); new EXTERNAL/INTERNAL badge + filter and a KPI strip (total/HTTP/forwards/external/disabled).
diff --git a/public/css/skoda.css b/public/css/skoda.css
new file mode 100644
index 00000000..108de67b
--- /dev/null
+++ b/public/css/skoda.css
@@ -0,0 +1,18 @@
+.skoda-page h2 { margin: 24px 0 12px; }
+.skoda-poll { display: inline-flex; align-items: center; gap: 8px; margin-right: 12px; font-size: 0.9em; }
+.skoda-poll input { width: 72px; }
+.skoda-account { display: flex; align-items: center; gap: 12px; padding: 8px 0; }
+.skoda-account-email { min-width: 220px; }
+.skoda-badge { padding: 2px 10px; border-radius: 999px; font-size: 0.8em; }
+.skoda-badge-ok { background: rgba(46, 160, 67, 0.15); color: #2ea043; }
+.skoda-badge-login_failed, .skoda-badge-error { background: rgba(218, 54, 51, 0.15); color: #da3633; }
+.skoda-badge-rate_limited { background: rgba(210, 153, 34, 0.15); color: #d29922; }
+.skoda-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 16px; }
+.skoda-card { border: 1px solid var(--border-color, rgba(128,128,128,0.25)); border-radius: 12px; padding: 16px; }
+.skoda-card-img { max-width: 100%; height: 120px; object-fit: contain; display: block; margin: 0 auto 8px; }
+.skoda-card-head { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; }
+.skoda-vin { font-size: 0.75em; opacity: 0.6; }
+.skoda-card-stats { display: flex; flex-wrap: wrap; gap: 8px 16px; margin: 10px 0; }
+.skoda-card-meta { font-size: 0.8em; opacity: 0.7; margin-bottom: 10px; }
+.skoda-card-actions { display: flex; gap: 8px; }
+.skoda-owner-item { display: block; padding: 4px 0; }
diff --git a/public/js/skoda.js b/public/js/skoda.js
new file mode 100644
index 00000000..ec0776ad
--- /dev/null
+++ b/public/js/skoda.js
@@ -0,0 +1,130 @@
+(() => {
+ 'use strict';
+ const GC = window.GC || {};
+ const T = (k, params) => {
+ let s = (GC.t && GC.t[k]) || k;
+ for (const [p, v] of Object.entries(params || {})) s = s.replace(`{{${p}}}`, v);
+ return s;
+ };
+ const headers = { 'Content-Type': 'application/json', 'x-csrf-token': GC.csrfToken };
+ async function api(method, path, body) {
+ const res = await fetch('/api/v1/skoda' + path, { method, headers, body: body ? JSON.stringify(body) : undefined });
+ const json = await res.json().catch(() => ({}));
+ if (!res.ok) throw Object.assign(new Error(json.error || res.statusText), { code: json.code });
+ return json;
+ }
+ async function apiRoot(method, path, body) {
+ const res = await fetch('/api/v1' + path, { method, headers, body: body ? JSON.stringify(body) : undefined });
+ const json = await res.json().catch(() => ({}));
+ if (!res.ok) throw Object.assign(new Error(json.error || res.statusText), { code: json.code });
+ return json;
+ }
+
+ const el = (id) => document.getElementById(id);
+ const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+ const showModal = (id) => { el(id).style.display = 'flex'; };
+ const hideModal = (id) => { el(id).style.display = 'none'; };
+ let current = { accounts: [], vehicles: [] };
+ let ownerVehicleId = null;
+
+ function accountRow(a) {
+ const statusKey = `skoda.accounts.status.${a.status}`;
+ const statusText = a.status === 'rate_limited'
+ ? T(statusKey, { time: a.next_retry_at ? new Date(a.next_retry_at).toLocaleTimeString() : '—' })
+ : T(statusKey);
+ return `
+ ${esc(a.email)}
+ ${esc(statusText)}
+
+
+
`;
+ }
+
+ function vehicleCard(v) {
+ const s = v.state || {};
+ const lock = s.locked === true ? T('skoda.vehicle.locked') : s.locked === false ? T('skoda.vehicle.unlocked') : '—';
+ // sqlite datetime('now') is "YYYY-MM-DD HH:MM:SS" in UTC -> proper ISO first
+ const fetched = v.fetched_at ? T('skoda.vehicle.fetched', { time: new Date(v.fetched_at.replace(' ', 'T') + 'Z').toLocaleString() }) : '—';
+ return `
+ ${v.has_image ? `

` : ''}
+
${esc(v.name || v.model || v.vin)}${esc(v.vin)}
+
+ ${T('skoda.vehicle.soc')}: ${s.soc ?? '—'}${s.soc != null ? '%' : ''}
+ ${T('skoda.vehicle.range')}: ${s.rangeKm ?? '—'}${s.rangeKm != null ? ' km' : ''}
+ ${lock}
+ ${T('skoda.vehicle.mileage')}: ${s.health && s.health.mileageKm != null ? s.health.mileageKm + ' km' : '—'}
+
+
${esc(fetched)} · ${T('skoda.vehicle.owners')}: ${esc((v.owners || []).map((o) => o.username).join(', ') || '—')}
+
+
+
+
+
`;
+ }
+
+ async function load() {
+ current = await api('GET', '');
+ el('skoda-poll-interval').value = current.poll_interval_min;
+ el('skoda-accounts').innerHTML = current.accounts.map(accountRow).join('') || '';
+ el('skoda-vehicles').innerHTML = current.vehicles.map(vehicleCard).join('') || `${T('skoda.vehicles.empty')}
`;
+ }
+
+ function fail(e) { alert(e.code === 'SKODA_REFRESH_COOLDOWN' ? T('skoda.error.cooldown') : (e.message || T('skoda.error.generic'))); }
+
+ el('skoda-account-add-open').addEventListener('click', () => showModal('skoda-account-modal'));
+ el('skoda-acc-cancel').addEventListener('click', () => hideModal('skoda-account-modal'));
+ el('skoda-acc-save').addEventListener('click', async () => {
+ try {
+ const created = await api('POST', '/accounts', { email: el('skoda-acc-email').value, password: el('skoda-acc-password').value });
+ api('POST', `/accounts/${created.account.id}/sync`).catch(() => {}); // fire and forget, status lands on the account row
+ hideModal('skoda-account-modal');
+ el('skoda-acc-email').value = ''; el('skoda-acc-password').value = '';
+ await load();
+ } catch (e) { fail(e); }
+ });
+
+ el('skoda-accounts').addEventListener('click', async (ev) => {
+ const btn = ev.target.closest('button'); if (!btn) return;
+ const id = Number(btn.closest('.skoda-account').dataset.id);
+ try {
+ if (btn.dataset.action === 'remove') { await api('DELETE', `/accounts/${id}`); await load(); }
+ if (btn.dataset.action === 'password') {
+ const pw = prompt(T('skoda.accounts.password'));
+ if (pw) {
+ await api('PUT', `/accounts/${id}`, { password: pw });
+ api('POST', `/accounts/${id}/sync`).catch(() => {});
+ await load();
+ }
+ }
+ } catch (e) { fail(e); }
+ });
+
+ el('skoda-vehicles').addEventListener('click', async (ev) => {
+ const btn = ev.target.closest('button'); if (!btn) return;
+ const id = Number(btn.closest('.skoda-card').dataset.id);
+ try {
+ if (btn.dataset.action === 'refresh') { await api('POST', `/vehicles/${id}/refresh`); await load(); }
+ if (btn.dataset.action === 'owners') {
+ ownerVehicleId = id;
+ const users = (await apiRoot('GET', '/users')).users || [];
+ const owned = new Set(((current.vehicles.find((v) => v.id === id) || {}).owners || []).map((o) => o.id));
+ el('skoda-owner-list').innerHTML = users.map((u) =>
+ ``).join('');
+ showModal('skoda-owner-modal');
+ }
+ } catch (e) { fail(e); }
+ });
+
+ el('skoda-owner-cancel').addEventListener('click', () => hideModal('skoda-owner-modal'));
+ el('skoda-owner-save').addEventListener('click', async () => {
+ const ids = [...el('skoda-owner-list').querySelectorAll('input:checked')].map((i) => Number(i.value));
+ try { await api('PUT', `/vehicles/${ownerVehicleId}/owners`, { user_ids: ids }); hideModal('skoda-owner-modal'); await load(); }
+ catch (e) { fail(e); }
+ });
+
+ el('skoda-poll-interval').addEventListener('change', async (ev) => {
+ try { await api('PUT', '/settings', { poll_interval_min: Number(ev.target.value) }); } catch (e) { fail(e); }
+ });
+
+ load().catch(fail);
+})();
diff --git a/src/i18n/de.json b/src/i18n/de.json
index c63d3e04..71756326 100644
--- a/src/i18n/de.json
+++ b/src/i18n/de.json
@@ -2007,6 +2007,7 @@
"settings.domains.in_use_portal": "Domain wird vom Portal genutzt — bitte zuerst die Portal-Adresse ändern.",
"nav.midea": "Klimaanlage",
"nav.smarthome": "Smart Home",
+ "nav.skoda": "Fahrzeuge",
"midea.title": "Klimaanlage",
"midea.subtitle": "Midea-Klimageräte im Heimnetz steuern",
"midea.cloud.title": "Midea-Konto",
@@ -2198,6 +2199,32 @@
"smarthome.rules.count_total": "Regeln insgesamt auf dem Gateway",
"smarthome.rules.count_gc": "Von GateControl verwaltet",
"smarthome.rules.count_external": "Externe Regeln",
+ "skoda.title": "Skoda Connect",
+ "skoda.accounts.title": "Skoda-Konten",
+ "skoda.accounts.add": "Konto hinzufügen",
+ "skoda.accounts.email": "E-Mail",
+ "skoda.accounts.password": "Passwort",
+ "skoda.accounts.status.ok": "Verbunden",
+ "skoda.accounts.status.login_failed": "Anmeldung fehlgeschlagen",
+ "skoda.accounts.status.rate_limited": "Rate-Limit — nächster Versuch: {{time}}",
+ "skoda.accounts.status.error": "Fehler",
+ "skoda.accounts.remove": "Entfernen",
+ "skoda.accounts.change_password": "Passwort ändern",
+ "skoda.vehicles.title": "Fahrzeuge",
+ "skoda.vehicles.empty": "Noch keine Fahrzeuge synchronisiert.",
+ "skoda.vehicle.soc": "Ladestand",
+ "skoda.vehicle.range": "Reichweite",
+ "skoda.vehicle.locked": "Verriegelt",
+ "skoda.vehicle.unlocked": "Entriegelt",
+ "skoda.vehicle.refresh": "Jetzt aktualisieren",
+ "skoda.vehicle.owners": "Besitzer",
+ "skoda.vehicle.fetched": "Stand: {{time}}",
+ "skoda.vehicle.mileage": "Kilometerstand",
+ "skoda.settings.poll_interval": "Abrufintervall (Minuten)",
+ "skoda.owner.title": "Besitzer zuordnen",
+ "skoda.owner.save": "Speichern",
+ "skoda.error.cooldown": "Bitte warten — Aktualisierung erst in ein paar Minuten wieder möglich.",
+ "skoda.error.generic": "Aktion fehlgeschlagen",
"portal.midea.fan": "Lüfter",
"portal.midea.fan_auto": "Auto",
"portal.midea.fan_silent": "Silent",
diff --git a/src/i18n/en.json b/src/i18n/en.json
index ae700c53..1d11f443 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -2063,6 +2063,7 @@
"settings.domains.in_use_portal": "Domain is in use by the portal — change the portal address first.",
"nav.midea": "Air Conditioning",
"nav.smarthome": "Smart Home",
+ "nav.skoda": "Vehicles",
"midea.title": "Air Conditioning",
"midea.subtitle": "Control Midea climate devices on your LAN",
"midea.cloud.title": "Midea account",
@@ -2254,6 +2255,32 @@
"smarthome.rules.count_total": "Total rules on gateway",
"smarthome.rules.count_gc": "Managed by GateControl",
"smarthome.rules.count_external": "External rules",
+ "skoda.title": "Skoda Connect",
+ "skoda.accounts.title": "Skoda accounts",
+ "skoda.accounts.add": "Add account",
+ "skoda.accounts.email": "Email",
+ "skoda.accounts.password": "Password",
+ "skoda.accounts.status.ok": "Connected",
+ "skoda.accounts.status.login_failed": "Login failed",
+ "skoda.accounts.status.rate_limited": "Rate limited — next retry: {{time}}",
+ "skoda.accounts.status.error": "Error",
+ "skoda.accounts.remove": "Remove",
+ "skoda.accounts.change_password": "Change password",
+ "skoda.vehicles.title": "Vehicles",
+ "skoda.vehicles.empty": "No vehicles synced yet.",
+ "skoda.vehicle.soc": "Charge level",
+ "skoda.vehicle.range": "Range",
+ "skoda.vehicle.locked": "Locked",
+ "skoda.vehicle.unlocked": "Unlocked",
+ "skoda.vehicle.refresh": "Refresh now",
+ "skoda.vehicle.owners": "Owners",
+ "skoda.vehicle.fetched": "As of: {{time}}",
+ "skoda.vehicle.mileage": "Mileage",
+ "skoda.settings.poll_interval": "Poll interval (minutes)",
+ "skoda.owner.title": "Assign owners",
+ "skoda.owner.save": "Save",
+ "skoda.error.cooldown": "Please wait — refresh available again in a few minutes.",
+ "skoda.error.generic": "Action failed",
"portal.midea.fan": "Fan",
"portal.midea.fan_auto": "Auto",
"portal.midea.fan_silent": "Silent",
diff --git a/src/routes/index.js b/src/routes/index.js
index ca2a624d..40394f75 100644
--- a/src/routes/index.js
+++ b/src/routes/index.js
@@ -194,6 +194,7 @@ const pages = [
{ path: '/dns', template: 'dns', titleKey: 'nav.dns' },
{ path: '/pihole', template: 'pihole', titleKey: 'pihole.title' },
{ path: '/midea', template: 'midea', titleKey: 'midea.title' },
+ { path: '/skoda', template: 'skoda', titleKey: 'skoda.title' },
{ path: '/smarthome', template: 'smarthome', titleKey: 'smarthome.title' },
{ path: '/smarthome/rules', template: 'smarthome-rules', titleKey: 'smarthome.rules.title' },
{ path: '/gateway-pools', template: 'gateway-pools', titleKey: 'gateway_pools.title' },
diff --git a/templates/aurora/layout.njk b/templates/aurora/layout.njk
index 4d0ba562..cfa62621 100644
--- a/templates/aurora/layout.njk
+++ b/templates/aurora/layout.njk
@@ -51,7 +51,8 @@
l4_routes: {{ license.features.l4_routes | default(0) }},
http_routes: {{ license.features.http_routes | default(0) }},
browser_sessions: {{ ('true' if license.hasFeature('browser_sessions') else 'false') | safe }},
- midea_integration: {{ ('true' if license.features.midea_integration else 'false') | safe }}
+ midea_integration: {{ ('true' if license.features.midea_integration else 'false') | safe }},
+ skoda_integration: {{ ('true' if license.features.skoda_integration else 'false') | safe }}
},
t: {
'smarthome.power': {{ t('smarthome.power') | dump | safe }},
@@ -534,7 +535,33 @@
'midea.extras.label': {{ t('midea.extras.label') | dump | safe }},
'midea.turbo': {{ t('midea.turbo') | dump | safe }},
'midea.eco': {{ t('midea.eco') | dump | safe }},
- 'midea.device.outdoor': {{ t('midea.device.outdoor') | dump | safe }}
+ 'midea.device.outdoor': {{ t('midea.device.outdoor') | dump | safe }},
+ 'skoda.title': {{ t('skoda.title') | dump | safe }},
+ 'skoda.accounts.title': {{ t('skoda.accounts.title') | dump | safe }},
+ 'skoda.accounts.add': {{ t('skoda.accounts.add') | dump | safe }},
+ 'skoda.accounts.email': {{ t('skoda.accounts.email') | dump | safe }},
+ 'skoda.accounts.password': {{ t('skoda.accounts.password') | dump | safe }},
+ 'skoda.accounts.status.ok': {{ t('skoda.accounts.status.ok') | dump | safe }},
+ 'skoda.accounts.status.login_failed': {{ t('skoda.accounts.status.login_failed') | dump | safe }},
+ 'skoda.accounts.status.rate_limited': {{ t('skoda.accounts.status.rate_limited') | dump | safe }},
+ 'skoda.accounts.status.error': {{ t('skoda.accounts.status.error') | dump | safe }},
+ 'skoda.accounts.remove': {{ t('skoda.accounts.remove') | dump | safe }},
+ 'skoda.accounts.change_password': {{ t('skoda.accounts.change_password') | dump | safe }},
+ 'skoda.vehicles.title': {{ t('skoda.vehicles.title') | dump | safe }},
+ 'skoda.vehicles.empty': {{ t('skoda.vehicles.empty') | dump | safe }},
+ 'skoda.vehicle.soc': {{ t('skoda.vehicle.soc') | dump | safe }},
+ 'skoda.vehicle.range': {{ t('skoda.vehicle.range') | dump | safe }},
+ 'skoda.vehicle.locked': {{ t('skoda.vehicle.locked') | dump | safe }},
+ 'skoda.vehicle.unlocked': {{ t('skoda.vehicle.unlocked') | dump | safe }},
+ 'skoda.vehicle.refresh': {{ t('skoda.vehicle.refresh') | dump | safe }},
+ 'skoda.vehicle.owners': {{ t('skoda.vehicle.owners') | dump | safe }},
+ 'skoda.vehicle.fetched': {{ t('skoda.vehicle.fetched') | dump | safe }},
+ 'skoda.vehicle.mileage': {{ t('skoda.vehicle.mileage') | dump | safe }},
+ 'skoda.settings.poll_interval': {{ t('skoda.settings.poll_interval') | dump | safe }},
+ 'skoda.owner.save': {{ t('skoda.owner.save') | dump | safe }},
+ 'skoda.owner.title': {{ t('skoda.owner.title') | dump | safe }},
+ 'skoda.error.cooldown': {{ t('skoda.error.cooldown') | dump | safe }},
+ 'skoda.error.generic': {{ t('skoda.error.generic') | dump | safe }}
}
};
diff --git a/templates/aurora/pages/skoda.njk b/templates/aurora/pages/skoda.njk
new file mode 100644
index 00000000..df093f86
--- /dev/null
+++ b/templates/aurora/pages/skoda.njk
@@ -0,0 +1,57 @@
+{% extends theme + "/layout.njk" %}
+{% block head %}{% endblock %}
+{% block content %}
+
+
+
{{ t('skoda.accounts.title') }}
+
+
{{ t('skoda.vehicles.title') }}
+
+
+
+
+
+
+
{{ t('skoda.accounts.add') }}
+
+
+
+
+
+
+
+
+
+
+
{{ t('skoda.owner.title') }}
+
+
+
+
+
+
+{% endblock %}
+{% block scripts %}{% endblock %}
diff --git a/templates/aurora/partials/sidebar.njk b/templates/aurora/partials/sidebar.njk
index 57013384..11bf5a80 100644
--- a/templates/aurora/partials/sidebar.njk
+++ b/templates/aurora/partials/sidebar.njk
@@ -27,6 +27,9 @@
{% if license.features.midea_integration %}
{{ t('nav.midea') }}
{% endif %}
+ {% if license.features.skoda_integration %}
+ {{ t('nav.skoda') }}
+ {% endif %}
{% if license.features.smarthome %}
{{ t('nav.smarthome') }}
{% endif %}
diff --git a/templates/default/layout.njk b/templates/default/layout.njk
index de651c9b..ad9c7c7a 100644
--- a/templates/default/layout.njk
+++ b/templates/default/layout.njk
@@ -50,7 +50,8 @@
l4_routes: {{ license.features.l4_routes | default(0) }},
http_routes: {{ license.features.http_routes | default(0) }},
browser_sessions: {{ ('true' if license.hasFeature('browser_sessions') else 'false') | safe }},
- midea_integration: {{ ('true' if license.features.midea_integration else 'false') | safe }}
+ midea_integration: {{ ('true' if license.features.midea_integration else 'false') | safe }},
+ skoda_integration: {{ ('true' if license.features.skoda_integration else 'false') | safe }}
},
t: {
'smarthome.power': {{ t('smarthome.power') | dump | safe }},
@@ -527,7 +528,33 @@
'midea.extras.label': {{ t('midea.extras.label') | dump | safe }},
'midea.turbo': {{ t('midea.turbo') | dump | safe }},
'midea.eco': {{ t('midea.eco') | dump | safe }},
- 'midea.device.outdoor': {{ t('midea.device.outdoor') | dump | safe }}
+ 'midea.device.outdoor': {{ t('midea.device.outdoor') | dump | safe }},
+ 'skoda.title': {{ t('skoda.title') | dump | safe }},
+ 'skoda.accounts.title': {{ t('skoda.accounts.title') | dump | safe }},
+ 'skoda.accounts.add': {{ t('skoda.accounts.add') | dump | safe }},
+ 'skoda.accounts.email': {{ t('skoda.accounts.email') | dump | safe }},
+ 'skoda.accounts.password': {{ t('skoda.accounts.password') | dump | safe }},
+ 'skoda.accounts.status.ok': {{ t('skoda.accounts.status.ok') | dump | safe }},
+ 'skoda.accounts.status.login_failed': {{ t('skoda.accounts.status.login_failed') | dump | safe }},
+ 'skoda.accounts.status.rate_limited': {{ t('skoda.accounts.status.rate_limited') | dump | safe }},
+ 'skoda.accounts.status.error': {{ t('skoda.accounts.status.error') | dump | safe }},
+ 'skoda.accounts.remove': {{ t('skoda.accounts.remove') | dump | safe }},
+ 'skoda.accounts.change_password': {{ t('skoda.accounts.change_password') | dump | safe }},
+ 'skoda.vehicles.title': {{ t('skoda.vehicles.title') | dump | safe }},
+ 'skoda.vehicles.empty': {{ t('skoda.vehicles.empty') | dump | safe }},
+ 'skoda.vehicle.soc': {{ t('skoda.vehicle.soc') | dump | safe }},
+ 'skoda.vehicle.range': {{ t('skoda.vehicle.range') | dump | safe }},
+ 'skoda.vehicle.locked': {{ t('skoda.vehicle.locked') | dump | safe }},
+ 'skoda.vehicle.unlocked': {{ t('skoda.vehicle.unlocked') | dump | safe }},
+ 'skoda.vehicle.refresh': {{ t('skoda.vehicle.refresh') | dump | safe }},
+ 'skoda.vehicle.owners': {{ t('skoda.vehicle.owners') | dump | safe }},
+ 'skoda.vehicle.fetched': {{ t('skoda.vehicle.fetched') | dump | safe }},
+ 'skoda.vehicle.mileage': {{ t('skoda.vehicle.mileage') | dump | safe }},
+ 'skoda.settings.poll_interval': {{ t('skoda.settings.poll_interval') | dump | safe }},
+ 'skoda.owner.save': {{ t('skoda.owner.save') | dump | safe }},
+ 'skoda.owner.title': {{ t('skoda.owner.title') | dump | safe }},
+ 'skoda.error.cooldown': {{ t('skoda.error.cooldown') | dump | safe }},
+ 'skoda.error.generic': {{ t('skoda.error.generic') | dump | safe }}
}
};
diff --git a/templates/default/pages/skoda.njk b/templates/default/pages/skoda.njk
new file mode 100644
index 00000000..df093f86
--- /dev/null
+++ b/templates/default/pages/skoda.njk
@@ -0,0 +1,57 @@
+{% extends theme + "/layout.njk" %}
+{% block head %}{% endblock %}
+{% block content %}
+
+
+
{{ t('skoda.accounts.title') }}
+
+
{{ t('skoda.vehicles.title') }}
+
+
+
+
+
+
+
{{ t('skoda.accounts.add') }}
+
+
+
+
+
+
+
+
+
+
+
{{ t('skoda.owner.title') }}
+
+
+
+
+
+
+{% endblock %}
+{% block scripts %}{% endblock %}
diff --git a/templates/default/partials/sidebar.njk b/templates/default/partials/sidebar.njk
index 88aca55f..98a0f035 100644
--- a/templates/default/partials/sidebar.njk
+++ b/templates/default/partials/sidebar.njk
@@ -64,6 +64,12 @@
{{ t('nav.midea') }}
{% endif %}
+ {% if license.features.skoda_integration %}
+
+
+ {{ t('nav.skoda') }}
+
+ {% endif %}
{% if license.features.smarthome %}
diff --git a/templates/pro/layout.njk b/templates/pro/layout.njk
index 3d531981..9e1f22d8 100644
--- a/templates/pro/layout.njk
+++ b/templates/pro/layout.njk
@@ -52,7 +52,8 @@
l4_routes: {{ license.features.l4_routes | default(0) }},
http_routes: {{ license.features.http_routes | default(0) }},
browser_sessions: {{ ('true' if license.hasFeature('browser_sessions') else 'false') | safe }},
- midea_integration: {{ ('true' if license.features.midea_integration else 'false') | safe }}
+ midea_integration: {{ ('true' if license.features.midea_integration else 'false') | safe }},
+ skoda_integration: {{ ('true' if license.features.skoda_integration else 'false') | safe }}
},
t: {
'smarthome.power': {{ t('smarthome.power') | dump | safe }},
@@ -529,7 +530,33 @@
'midea.extras.label': {{ t('midea.extras.label') | dump | safe }},
'midea.turbo': {{ t('midea.turbo') | dump | safe }},
'midea.eco': {{ t('midea.eco') | dump | safe }},
- 'midea.device.outdoor': {{ t('midea.device.outdoor') | dump | safe }}
+ 'midea.device.outdoor': {{ t('midea.device.outdoor') | dump | safe }},
+ 'skoda.title': {{ t('skoda.title') | dump | safe }},
+ 'skoda.accounts.title': {{ t('skoda.accounts.title') | dump | safe }},
+ 'skoda.accounts.add': {{ t('skoda.accounts.add') | dump | safe }},
+ 'skoda.accounts.email': {{ t('skoda.accounts.email') | dump | safe }},
+ 'skoda.accounts.password': {{ t('skoda.accounts.password') | dump | safe }},
+ 'skoda.accounts.status.ok': {{ t('skoda.accounts.status.ok') | dump | safe }},
+ 'skoda.accounts.status.login_failed': {{ t('skoda.accounts.status.login_failed') | dump | safe }},
+ 'skoda.accounts.status.rate_limited': {{ t('skoda.accounts.status.rate_limited') | dump | safe }},
+ 'skoda.accounts.status.error': {{ t('skoda.accounts.status.error') | dump | safe }},
+ 'skoda.accounts.remove': {{ t('skoda.accounts.remove') | dump | safe }},
+ 'skoda.accounts.change_password': {{ t('skoda.accounts.change_password') | dump | safe }},
+ 'skoda.vehicles.title': {{ t('skoda.vehicles.title') | dump | safe }},
+ 'skoda.vehicles.empty': {{ t('skoda.vehicles.empty') | dump | safe }},
+ 'skoda.vehicle.soc': {{ t('skoda.vehicle.soc') | dump | safe }},
+ 'skoda.vehicle.range': {{ t('skoda.vehicle.range') | dump | safe }},
+ 'skoda.vehicle.locked': {{ t('skoda.vehicle.locked') | dump | safe }},
+ 'skoda.vehicle.unlocked': {{ t('skoda.vehicle.unlocked') | dump | safe }},
+ 'skoda.vehicle.refresh': {{ t('skoda.vehicle.refresh') | dump | safe }},
+ 'skoda.vehicle.owners': {{ t('skoda.vehicle.owners') | dump | safe }},
+ 'skoda.vehicle.fetched': {{ t('skoda.vehicle.fetched') | dump | safe }},
+ 'skoda.vehicle.mileage': {{ t('skoda.vehicle.mileage') | dump | safe }},
+ 'skoda.settings.poll_interval': {{ t('skoda.settings.poll_interval') | dump | safe }},
+ 'skoda.owner.save': {{ t('skoda.owner.save') | dump | safe }},
+ 'skoda.owner.title': {{ t('skoda.owner.title') | dump | safe }},
+ 'skoda.error.cooldown': {{ t('skoda.error.cooldown') | dump | safe }},
+ 'skoda.error.generic': {{ t('skoda.error.generic') | dump | safe }}
}
};
diff --git a/templates/pro/pages/skoda.njk b/templates/pro/pages/skoda.njk
new file mode 100644
index 00000000..df093f86
--- /dev/null
+++ b/templates/pro/pages/skoda.njk
@@ -0,0 +1,57 @@
+{% extends theme + "/layout.njk" %}
+{% block head %}{% endblock %}
+{% block content %}
+
+
+
{{ t('skoda.accounts.title') }}
+
+
{{ t('skoda.vehicles.title') }}
+
+
+
+
+
+
+
{{ t('skoda.accounts.add') }}
+
+
+
+
+
+
+
+
+
+
+
{{ t('skoda.owner.title') }}
+
+
+
+
+
+
+{% endblock %}
+{% block scripts %}{% endblock %}
diff --git a/templates/pro/partials/sidebar.njk b/templates/pro/partials/sidebar.njk
index 90b75f7c..d0513322 100644
--- a/templates/pro/partials/sidebar.njk
+++ b/templates/pro/partials/sidebar.njk
@@ -81,6 +81,12 @@
{{ t('nav.midea') }}
{% endif %}
+ {% if license.features.skoda_integration %}
+
+
+ {{ t('nav.skoda') }}
+
+ {% endif %}
{% if license.features.smarthome %}
diff --git a/tests/skoda_i18n.test.js b/tests/skoda_i18n.test.js
new file mode 100644
index 00000000..100c844f
--- /dev/null
+++ b/tests/skoda_i18n.test.js
@@ -0,0 +1,33 @@
+'use strict';
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const de = require('../src/i18n/de.json');
+const en = require('../src/i18n/en.json');
+
+const KEYS = [
+ 'skoda.title', 'skoda.accounts.title', 'skoda.accounts.add', 'skoda.accounts.email',
+ 'skoda.accounts.password', 'skoda.accounts.status.ok', 'skoda.accounts.status.login_failed',
+ 'skoda.accounts.status.rate_limited', 'skoda.accounts.status.error', 'skoda.accounts.remove',
+ 'skoda.accounts.change_password', 'skoda.vehicles.title', 'skoda.vehicles.empty',
+ 'skoda.vehicle.soc', 'skoda.vehicle.range', 'skoda.vehicle.locked', 'skoda.vehicle.unlocked',
+ 'skoda.vehicle.refresh', 'skoda.vehicle.owners', 'skoda.vehicle.fetched', 'skoda.vehicle.mileage',
+ 'skoda.settings.poll_interval', 'skoda.owner.save', 'skoda.owner.title',
+ 'nav.skoda', 'skoda.error.cooldown', 'skoda.error.generic',
+];
+
+test('all skoda keys exist in de and en', () => {
+ for (const k of KEYS) {
+ assert.ok(de[k] && de[k].trim(), `de missing ${k}`);
+ assert.ok(en[k] && en[k].trim(), `en missing ${k}`);
+ }
+});
+
+test('client-side keys are in all three layout GC.t whitelists', () => {
+ const CLIENT_KEYS = KEYS.filter((k) => k.startsWith('skoda.'));
+ for (const theme of ['aurora', 'default', 'pro']) {
+ const layout = fs.readFileSync(path.join(__dirname, '..', 'templates', theme, 'layout.njk'), 'utf8');
+ for (const k of CLIENT_KEYS) assert.ok(layout.includes(`'${k}'`), `${theme} layout missing ${k}`);
+ }
+});
diff --git a/tests/skoda_page.test.js b/tests/skoda_page.test.js
new file mode 100644
index 00000000..f1ad692b
--- /dev/null
+++ b/tests/skoda_page.test.js
@@ -0,0 +1,16 @@
+'use strict';
+const { test, before, after } = require('node:test');
+const assert = require('node:assert/strict');
+const nodeCrypto = require('node:crypto');
+process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || nodeCrypto.randomBytes(32).toString('hex');
+const { setup, teardown } = require('./helpers/setup');
+let ctx;
+before(async () => { ctx = await setup(); });
+after(async () => { await teardown(); });
+
+test('GET /skoda renders the admin page', async () => {
+ const res = await ctx.agent.get('/skoda');
+ assert.equal(res.status, 200);
+ assert.match(res.text, /skoda-page/);
+ assert.match(res.text, /\/js\/skoda\.js/);
+});
From 8011d7dc096311320442099b6696e750e854b330 Mon Sep 17 00:00:00 2001
From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com>
Date: Wed, 22 Jul 2026 12:42:12 +0200
Subject: [PATCH 14/15] fix(skoda): no immediate sync on poll-interval change,
default-theme btn-sm, drop dead import
---
public/css/skoda.css | 1 +
src/routes/api/skoda.js | 2 +-
src/services/skoda/index.js | 4 ++--
src/services/skoda/skodaAuth.js | 2 +-
tests/skoda_api.test.js | 4 ++++
5 files changed, 9 insertions(+), 4 deletions(-)
diff --git a/public/css/skoda.css b/public/css/skoda.css
index 108de67b..d6fa1d0b 100644
--- a/public/css/skoda.css
+++ b/public/css/skoda.css
@@ -16,3 +16,4 @@
.skoda-card-meta { font-size: 0.8em; opacity: 0.7; margin-bottom: 10px; }
.skoda-card-actions { display: flex; gap: 8px; }
.skoda-owner-item { display: block; padding: 4px 0; }
+.skoda-page .btn-sm{padding:6px 11px;font-size:12.5px;border-radius:9px} /* app.css lacks .btn-sm */
diff --git a/src/routes/api/skoda.js b/src/routes/api/skoda.js
index 0bf80628..e817b2bc 100644
--- a/src/routes/api/skoda.js
+++ b/src/routes/api/skoda.js
@@ -92,7 +92,7 @@ router.put('/settings', wrap(async (req, res) => {
}
settings.set('skoda_poll_interval_min', String(val));
skoda.stopPolling();
- skoda.startPolling();
+ skoda.startPolling({ immediate: false });
res.json({ ok: true });
}));
diff --git a/src/services/skoda/index.js b/src/services/skoda/index.js
index 22a871d4..5c93e9e2 100644
--- a/src/services/skoda/index.js
+++ b/src/services/skoda/index.js
@@ -163,12 +163,12 @@ function pollTick() {
.finally(() => { pollRunning = false; });
}
-function startPolling() {
+function startPolling({ immediate = true } = {}) {
if (pollTimer) return;
if (!license.hasFeature(FEATURE)) return;
pollTimer = setInterval(pollTick, pollIntervalMs());
pollTimer.unref();
- pollTick();
+ if (immediate) pollTick();
}
function stopPolling() {
diff --git a/src/services/skoda/skodaAuth.js b/src/services/skoda/skodaAuth.js
index 76ce38f8..e2c9ee1c 100644
--- a/src/services/skoda/skodaAuth.js
+++ b/src/services/skoda/skodaAuth.js
@@ -4,7 +4,7 @@
// The API is unofficial; scripts/skoda-spike.js is the live ground truth.
const crypto = require('node:crypto');
-const { CookieJar, requestWithJar, followRedirects } = require('./skodaHttp');
+const { CookieJar, followRedirects } = require('./skodaHttp');
const CLIENT_ID = '7f045eee-7003-4379-9968-9355ed2adb06@apps_vw-dilab_com';
const REDIRECT_URI = 'myskoda://redirect/login/';
diff --git a/tests/skoda_api.test.js b/tests/skoda_api.test.js
index 46c1cf54..dc62c83e 100644
--- a/tests/skoda_api.test.js
+++ b/tests/skoda_api.test.js
@@ -114,11 +114,15 @@ test('refresh cooldown maps to 429', async () => {
});
test('settings PUT validates range and persists', async () => {
+ const { mock } = require('node:test');
let res = await ctx.agent.put('/api/v1/skoda/settings').set('x-csrf-token', ctx.csrfToken).send({ poll_interval_min: 3 });
assert.equal(res.status, 400);
+ const m = mock.method(skoda, 'syncAccount', async () => ({ ok: true, vehicles: 0 }));
res = await ctx.agent.put('/api/v1/skoda/settings').set('x-csrf-token', ctx.csrfToken).send({ poll_interval_min: 30 });
assert.equal(res.status, 200);
assert.equal(require('../src/services/settings').get('skoda_poll_interval_min'), '30');
+ assert.equal(m.mock.callCount(), 0);
+ m.mock.restore();
});
test('unauthenticated request is rejected', async () => {
From dcdaec35a0a489116a634b9c196e01d0850559ba Mon Sep 17 00:00:00 2001
From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com>
Date: Wed, 22 Jul 2026 16:41:43 +0200
Subject: [PATCH 15/15] fix(skoda): replace ReDoS-prone email regex with linear
index check
CodeQL js/polynomial-redos flagged /.+@.+/ on the admin-supplied email as a
polynomial regular expression on uncontrolled data. Swap for an O(n) indexOf
check plus the RFC 5321 254-char cap; behaviour is unchanged for valid input.
---
src/services/skoda/skodaAccounts.js | 14 ++++++++++++--
tests/skoda_accounts.test.js | 2 ++
2 files changed, 14 insertions(+), 2 deletions(-)
diff --git a/src/services/skoda/skodaAccounts.js b/src/services/skoda/skodaAccounts.js
index aa87ecaa..84e9ec59 100644
--- a/src/services/skoda/skodaAccounts.js
+++ b/src/services/skoda/skodaAccounts.js
@@ -5,13 +5,23 @@ const { encrypt, decrypt } = require('../../utils/crypto');
function err(message, code) { const e = new Error(message); e.code = code; return e; }
+function isValidEmail(email) {
+ // Linear index checks instead of a regex: /.+@.+/ on user input is a
+ // polynomial-ReDoS risk (CodeQL js/polynomial-redos). indexOf is O(n) and
+ // the length cap is the RFC 5321 maximum.
+ if (email.length > 254) return false;
+ const at = email.indexOf('@');
+ return at > 0 && at < email.length - 1;
+}
+
function createAccount({ email, password }) {
- if (!email || typeof email !== 'string' || !/.+@.+/.test(email.trim())) throw err('valid email required', 'SKODA_VALIDATION');
+ const trimmed = typeof email === 'string' ? email.trim() : '';
+ if (!trimmed || !isValidEmail(trimmed)) throw err('valid email required', 'SKODA_VALIDATION');
if (!password || typeof password !== 'string') throw err('password required', 'SKODA_VALIDATION');
const db = getDb();
try {
const info = db.prepare('INSERT INTO skoda_accounts (email, password_enc) VALUES (?, ?)')
- .run(email.trim(), encrypt(password));
+ .run(trimmed, encrypt(password));
return listAccounts().find((a) => a.id === info.lastInsertRowid);
} catch (e) {
if (/UNIQUE/.test(e.message)) throw err('account already exists', 'SKODA_ACCOUNT_EXISTS');
diff --git a/tests/skoda_accounts.test.js b/tests/skoda_accounts.test.js
index 15813279..f82eabf9 100644
--- a/tests/skoda_accounts.test.js
+++ b/tests/skoda_accounts.test.js
@@ -33,6 +33,8 @@ test('empty or malformed fields raise SKODA_VALIDATION', () => {
assert.throws(() => accounts.createAccount({ email: '', password: 'x' }), (e) => e.code === 'SKODA_VALIDATION');
assert.throws(() => accounts.createAccount({ email: 'keine-mail', password: 'x' }), (e) => e.code === 'SKODA_VALIDATION');
assert.throws(() => accounts.createAccount({ email: 'a@b.c', password: '' }), (e) => e.code === 'SKODA_VALIDATION');
+ // ReDoS guard: an overlong string must be rejected in bounded time (RFC 5321 cap)
+ assert.throws(() => accounts.createAccount({ email: `${'a'.repeat(9000)}@x`, password: 'x' }), (e) => e.code === 'SKODA_VALIDATION');
});
test('setStatus caps status_detail length at 300 chars', () => {