Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -2403,6 +2402,7 @@ fastify.get('/api/devices', async (request, reply) => {

return {
...device,
updateTime: sqliteUtcToIso(device.updateTime),
widgets: widgetCount,
};
});
Expand Down Expand Up @@ -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) {
Expand Down
23 changes: 23 additions & 0 deletions server/tests/apiEndpoints.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`
);
});
62 changes: 62 additions & 0 deletions server/tests/sqliteTime.test.js
Original file line number Diff line number Diff line change
@@ -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)}`);
}
});
51 changes: 51 additions & 0 deletions server/utils/sqliteTime.js
Original file line number Diff line number Diff line change
@@ -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,
};