diff --git a/server/index.js b/server/index.js index 78c1c97..8bb3c98 100644 --- a/server/index.js +++ b/server/index.js @@ -118,6 +118,7 @@ const { decryptLegacy, } = require('./utils/encryption'); const { httpsAgentFor, isCertificateVerificationSkipped } = require('./utils/outboundTls'); +const { sqliteUtcToIso, sqliteUtcToMs } = require('./utils/sqliteTime'); const { DEVICE_NAME_RULE_MESSAGE, isValidDeviceName, @@ -2241,9 +2242,7 @@ function parseTabConfigJson(configJson) { function getDeviceUpdateTimeMs(deviceName) { const row = db.prepare('SELECT updateTime FROM devices WHERE name = ?').get(deviceName); - if (!row?.updateTime) return null; - const timestamp = Date.parse(row.updateTime); - return Number.isFinite(timestamp) ? timestamp : null; + return sqliteUtcToMs(row?.updateTime); } function sendJsonWithConditionalCache(request, reply, payload, lastModifiedMs = null) { @@ -2403,6 +2402,7 @@ fastify.get('/api/devices', async (request, reply) => { return { ...device, + updateTime: sqliteUtcToIso(device.updateTime), widgets: widgetCount, }; }); @@ -4912,8 +4912,8 @@ fastify.get('/api/connections/google/status', async (request, reply) => { name: account.name, picture: account.picture, scopes: account.scopes, - connected_at: account.created_at, - updated_at: account.updated_at, + connected_at: sqliteUtcToIso(account.created_at), + updated_at: sqliteUtcToIso(account.updated_at), } : null, }; } catch (error) { diff --git a/server/tests/apiEndpoints.test.js b/server/tests/apiEndpoints.test.js index 7af6481..8d59878 100644 --- a/server/tests/apiEndpoints.test.js +++ b/server/tests/apiEndpoints.test.js @@ -760,3 +760,26 @@ test('chore schedule rejects an impossible due_date', async () => { assert.equal(createRes.status, 400); assert.match(createRes.body.error, /due_date/); }); + +test('GET /api/devices reports updateTime as an explicit UTC instant', async () => { + const deviceName = `instant-device-${Date.now()}`; + const settingsRes = await api(`/api/devices/${encodeURIComponent(deviceName)}/settings`, { + method: 'PATCH', + body: JSON.stringify({ theme: 'dark' }), + }); + assert.equal(settingsRes.status, 200); + + const { status, body } = await api('/api/devices'); + assert.equal(status, 200); + + const device = body.find((entry) => entry.name === deviceName); + assert.ok(device, 'expected the device to appear in /api/devices'); + + // The stored column is "YYYY-MM-DD HH:MM:SS" with no zone marker, which + // every consumer reads as local time. The response must carry the marker. + assert.match(device.updateTime, /Z$/); + assert.ok( + Math.abs(Date.parse(device.updateTime) - Date.now()) < 120000, + `updateTime ${device.updateTime} is not close to now` + ); +}); diff --git a/server/tests/sqliteTime.test.js b/server/tests/sqliteTime.test.js new file mode 100644 index 0000000..9cfcfd8 --- /dev/null +++ b/server/tests/sqliteTime.test.js @@ -0,0 +1,62 @@ +// SQLite writes CURRENT_TIMESTAMP as UTC with no zone marker, and V8 reads such +// a string as local time. The bug is invisible in a UTC process, which is where +// CI runs — so these tests set a non-UTC zone and assert the naive parse is +// wrong there before asserting the helper is right. Without that control the +// suite would pass in UTC no matter what the helper did. + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { sqliteUtcToIso, sqliteUtcToMs } = require('../utils/sqliteTime'); + +const STORED = '2026-09-02 04:50:32'; +const INSTANT_MS = Date.UTC(2026, 8, 2, 4, 50, 32); + +function withTimeZone(tz, fn) { + const original = process.env.TZ; + process.env.TZ = tz; + try { + fn(); + } finally { + if (original === undefined) delete process.env.TZ; + else process.env.TZ = original; + } +} + +test('a stored timestamp reads as the same instant in any zone', () => { + for (const tz of ['UTC', 'America/Los_Angeles', 'Asia/Kolkata', 'Pacific/Auckland']) { + withTimeZone(tz, () => { + assert.equal(sqliteUtcToMs(STORED), INSTANT_MS, `wrong instant under ${tz}`); + }); + } +}); + +test('the naive parse this replaces is demonstrably wrong west of UTC', () => { + withTimeZone('America/Los_Angeles', () => { + // Positive control: if this ever equals INSTANT_MS the platform has + // changed and the test above no longer proves anything. + assert.notEqual(Date.parse(STORED), INSTANT_MS); + assert.equal(sqliteUtcToMs(STORED), INSTANT_MS); + }); +}); + +test('sqliteUtcToIso marks the zone explicitly', () => { + assert.equal(sqliteUtcToIso(STORED), '2026-09-02T04:50:32.000Z'); +}); + +test('fractional seconds are preserved', () => { + assert.equal(sqliteUtcToIso('2026-09-02 04:50:32.250'), '2026-09-02T04:50:32.250Z'); +}); + +test('an ISO value that already carries a zone is passed through unchanged', () => { + assert.equal(sqliteUtcToIso('2026-09-02T04:50:32Z'), '2026-09-02T04:50:32.000Z'); + assert.equal(sqliteUtcToMs('2026-09-02T04:50:32+00:00'), INSTANT_MS); + assert.equal(sqliteUtcToMs('2026-09-01T21:50:32-07:00'), INSTANT_MS); +}); + +test('unusable values return null rather than a plausible wrong time', () => { + for (const bad of [null, undefined, '', ' ', 'garbage', 42, {}, '2026-09-02', '2026-99-99 04:50:32']) { + assert.equal(sqliteUtcToMs(bad), null, `expected null for ${JSON.stringify(bad)}`); + assert.equal(sqliteUtcToIso(bad), null, `expected null for ${JSON.stringify(bad)}`); + } +}); diff --git a/server/utils/sqliteTime.js b/server/utils/sqliteTime.js new file mode 100644 index 0000000..76cf9a2 --- /dev/null +++ b/server/utils/sqliteTime.js @@ -0,0 +1,51 @@ +// SQLite's CURRENT_TIMESTAMP is always UTC, by specification — it ignores the +// TZ environment variable, so a correctly configured container still writes +// UTC. What it writes is "YYYY-MM-DD HH:MM:SS", which carries no zone marker. +// +// Neither Date.parse nor the browser's new Date() treats that as UTC. V8 reads +// a space-separated, offset-less timestamp as LOCAL time, so the value comes +// back an offset away from the instant that was stored — seven hours in +// US/Pacific. The digits look plausible, which is why this survives review. +// +// Columns written by CURRENT_TIMESTAMP are read through here, so the +// conversion lives in one place rather than at each call site. + +const SQLITE_UTC_TIMESTAMP = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})(\.\d+)?$/; + +// Values that already carry a zone (Z or ±hh:mm) are unambiguous and are left +// to the platform parser. +const HAS_EXPLICIT_ZONE = /(?:Z|[+-]\d{2}:?\d{2})$/i; + +/** Epoch milliseconds for a stored timestamp, or null if it is unusable. */ +function sqliteUtcToMs(value) { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + if (!trimmed) return null; + + if (HAS_EXPLICIT_ZONE.test(trimmed)) { + const parsed = Date.parse(trimmed); + return Number.isFinite(parsed) ? parsed : null; + } + + const match = SQLITE_UTC_TIMESTAMP.exec(trimmed); + if (!match) return null; + + const parsed = Date.parse(`${match[1]}-${match[2]}-${match[3]}T${match[4]}:${match[5]}:${match[6]}${match[7] || ''}Z`); + return Number.isFinite(parsed) ? parsed : null; +} + +/** + * ISO-8601 with an explicit Z, for anything crossing the API boundary. Returns + * null rather than the raw string when the value cannot be read as a UTC + * instant: a client shows "Unknown" for null, but renders a wrong local time + * for an ambiguous string, and being wrong is worse than admitting ignorance. + */ +function sqliteUtcToIso(value) { + const ms = sqliteUtcToMs(value); + return ms === null ? null : new Date(ms).toISOString(); +} + +module.exports = { + sqliteUtcToIso, + sqliteUtcToMs, +};