openvibers/openvibe.network Commits
JSON times carry their zone (ISO 8601 Z) on the API edge (#870)
Changes
@@@ -44,6 +44,8 @@ const { signToken } = require('./auth/routes');const ready = (async () => {const app = express();++// Times leave with their zone (openvibe.design rule 148): the SQLite-era text columns are UTC.++app.use(require('./iso-times').isoJsonTimes);// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts// (openvibe-contracts); the operations they mark internal-only are left out.app.get('/openapi.json', (_req, res) => {nnew file mode 100644@@@ -0,0 +1,46 @@++'use strict';++/**++ * Times leave with their zone (openvibe.design rule 148).++ *++ * The tables created from SQLite (migrations/0001_initial.sql) keep timestamps as text in the++ * SQLite shape `YYYY-MM-DD HH:MM:SS`, which ov_now() writes in UTC. A browser reads that string as++ * local time, so west of UTC every event looked hours in the future. This is the edge: JSON fields++ * whose name says they are a time are rendered as ISO 8601 with Z, and nothing else changes — the++ * stored text, cursors built from it and every query that compares it stay exactly as they are.++ *++ * Already-ISO values (with Z or an offset), dates (`YYYY-MM-DD`) and non-strings pass through.++ * `POST /api/v1/...` cursors echo their own stored text, not one of these fields, so pagination is++ * unaffected.++ */++const SQL_TEXT_TIME = /^\d{4}-\d\d-\d\d[ T]\d\d:\d\d(?::\d\d(?:\.\d+)?)?$/;++// created_at, updated_at, started_at, ended_at, expires_at, last_message_at, timestamp, …++// and the bare `created` / `updated` / `started` / `ended` / `expires` some responses use.++const TIME_KEY = /(^|_)(at|created|updated|started|ended|expires|timestamp)$|^last_|^(since|until)$/;++++/** One stored time as ISO 8601 with Z; anything not in the SQLite shape is returned untouched. */++function isoTime(value) {++ return typeof value === 'string' && SQL_TEXT_TIME.test(value) ? `${value.replace(' ', 'T')}Z` : value;++}++++/** Copy `value`, rendering time-named fields; arrays and plain objects are walked, a Date is not. */++function normalizeTimes(value, depth = 0) {++ if (depth > 8 || value === null || typeof value !== 'object') return value;++ if (Array.isArray(value)) return value.map((item) => normalizeTimes(item, depth + 1));++ const proto = Object.getPrototypeOf(value);++ if (proto !== Object.prototype && proto !== null) return value;++ const out = {};++ for (const [key, item] of Object.entries(value)) {++ out[key] = TIME_KEY.test(key) ? isoTime(item) : normalizeTimes(item, depth + 1);++ }++ return out;++}++++/** Express middleware: JSON answers carry ISO times. Internal service routes keep their SQL shape. */++function isoJsonTimes(req, res, next) {++ if (req.path.startsWith('/internal/')) return next();++ const json = res.json.bind(res);++ res.json = (body) => json(normalizeTimes(body));++ next();++}++++module.exports = { TIME_KEY, SQL_TEXT_TIME, isoTime, normalizeTimes, isoJsonTimes };nnew file mode 100644@@@ -0,0 +1,59 @@++'use strict';++/**++ * Times leave with their zone (openvibe.design rule 148): Network's SQLite-era text timestamps are UTC++ * but a browser reads `YYYY-MM-DD HH:MM:SS` as local time. The JSON edge renders time fields as ISO++ * 8601 with Z; stored text, cursors and every query that compares it stay unchanged.++ */++const assert = require('assert');++const crypto = require('crypto');++const http = require('http');++const express = require('express');++const { getDb } = require('../server/db/database');++const { isoTime, normalizeTimes, isoJsonTimes } = require('../server/iso-times');++++assert.strictEqual(isoTime('2026-11-12 05:01:02'), '2026-11-12T05:01:02Z');++assert.strictEqual(isoTime('2026-11-12T05:01:02.123Z'), '2026-11-12T05:01:02.123Z', 'ISO with Z is left alone');++assert.strictEqual(isoTime('2026-11-12T05:01:02+02:00'), '2026-11-12T05:01:02+02:00', 'ISO with an offset is left alone');++assert.strictEqual(isoTime('2026-11-12'), '2026-11-12', 'a date is not a time');++assert.strictEqual(isoTime(null), null);++assert.deepStrictEqual(++ normalizeTimes({ id: 1, created_at: '2026-11-12 05:01:02', updated_at: null, when: '2026-11-12 05:01:02', meta: { last_seen: '2026-11-12 05:01:02' }, tags: [{ expires_at: '2026-11-12 05:01:02' }] }),++ { id: 1, created_at: '2026-11-12T05:01:02Z', updated_at: null, when: '2026-11-12 05:01:02', meta: { last_seen: '2026-11-12T05:01:02Z' }, tags: [{ expires_at: '2026-11-12T05:01:02Z' }] },++ 'time-named fields are rendered; body text that looks like a time is not',++);++++(async () => {++ const log = console.log; console.log = () => {};++ const db = getDb();++ console.log = log;++ await db.prepare("INSERT INTO users (id, username, password_hash, role) VALUES (41, 'zone', 'x', 'user') ON CONFLICT DO NOTHING").run();++ for (const [id, name] of [['vibe', 'Vibe'], ['cloud', 'Cloud']]) {++ await db.prepare("INSERT INTO themes (id, name, slug, mode, variables, is_builtin, is_public) VALUES (?, ?, ?, 'dark', '{}', 1, 1) ON CONFLICT DO NOTHING").run(id, name, id);++ }++ const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });++ const config = { jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } };++ const { signToken } = require('../server/auth/routes');++ const app = express();++ app.use(express.json());++ app.use(isoJsonTimes);++ Object.assign(app.locals, { db, config, privateKey: keys.privateKey, publicKey: keys.publicKey });++ app.use('/api/themes', require('../server/themes/routes'));++ const server = http.createServer(app);++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ const base = `http://127.0.0.1:${server.address().port}`;++ const token = await signToken(await db.prepare('SELECT * FROM users WHERE id = 41').get(), keys.privateKey, config);++ const call = async (method, p, body) => fetch(base + p, { method, headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, body: body ? JSON.stringify(body) : undefined }).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));++++ await call('PUT', '/api/themes/me', { theme_id: 'vibe' });++ await call('PUT', '/api/themes/me', { theme_id: 'cloud' });++ const r = await call('GET', '/api/themes/me/history');++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));++ assert.ok(r.body.history.length > 0, 'the history has the replaced setting');++ for (const entry of r.body.history) assert.match(entry.created_at, /Z$/, 'theme history created_at is ISO with Z');++ const row = await db.prepare('SELECT created_at FROM user_theme_history WHERE user_id = 41 ORDER BY id DESC LIMIT 1').get();++ assert.match(row.created_at, /^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d$/, 'the stored text is untouched');++++ await new Promise((r2) => server.close(r2));++ console.log('iso times: helper and GET /api/themes/me/history checked');++ process.exit(0);++})().catch((err) => { console.error(err); process.exit(1); });
@@ -44,6 +44,8 @@ const { signToken } = require('./auth/routes');
@@ -44,6 +44,8 @@ const { signToken } = require('./auth/routes');
const ready = (async () => {
const ready = (async () => {
const app = express();
const app = express();
+// Times leave with their zone (openvibe.design rule 148): the SQLite-era text columns are UTC.
+app.use(require('./iso-times').isoJsonTimes);
// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts
// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts
// (openvibe-contracts); the operations they mark internal-only are left out.
// (openvibe-contracts); the operations they mark internal-only are left out.
app.get('/openapi.json', (_req, res) => {
app.get('/openapi.json', (_req, res) => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,46 @@
@@ -0,0 +1,46 @@
+'use strict';
+/**
+ * Times leave with their zone (openvibe.design rule 148).
+ *
+ * The tables created from SQLite (migrations/0001_initial.sql) keep timestamps as text in the
+ * SQLite shape `YYYY-MM-DD HH:MM:SS`, which ov_now() writes in UTC. A browser reads that string as
+ * local time, so west of UTC every event looked hours in the future. This is the edge: JSON fields
+ * whose name says they are a time are rendered as ISO 8601 with Z, and nothing else changes — the
+ * stored text, cursors built from it and every query that compares it stay exactly as they are.
+ *
+ * Already-ISO values (with Z or an offset), dates (`YYYY-MM-DD`) and non-strings pass through.
+ * `POST /api/v1/...` cursors echo their own stored text, not one of these fields, so pagination is
+ * unaffected.
+ */
+const SQL_TEXT_TIME = /^\d{4}-\d\d-\d\d[ T]\d\d:\d\d(?::\d\d(?:\.\d+)?)?$/;
+// created_at, updated_at, started_at, ended_at, expires_at, last_message_at, timestamp, …
+// and the bare `created` / `updated` / `started` / `ended` / `expires` some responses use.
+const TIME_KEY = /(^|_)(at|created|updated|started|ended|expires|timestamp)$|^last_|^(since|until)$/;
+
+/** One stored time as ISO 8601 with Z; anything not in the SQLite shape is returned untouched. */
+function isoTime(value) {
+ return typeof value === 'string' && SQL_TEXT_TIME.test(value) ? `${value.replace(' ', 'T')}Z` : value;
+}
+
+/** Copy `value`, rendering time-named fields; arrays and plain objects are walked, a Date is not. */
+function normalizeTimes(value, depth = 0) {
+ if (depth > 8 || value === null || typeof value !== 'object') return value;
+ if (Array.isArray(value)) return value.map((item) => normalizeTimes(item, depth + 1));
+ const proto = Object.getPrototypeOf(value);
+ if (proto !== Object.prototype && proto !== null) return value;
+ const out = {};
+ for (const [key, item] of Object.entries(value)) {
+ out[key] = TIME_KEY.test(key) ? isoTime(item) : normalizeTimes(item, depth + 1);
+ }
+ return out;
+}
+
+/** Express middleware: JSON answers carry ISO times. Internal service routes keep their SQL shape. */
+function isoJsonTimes(req, res, next) {
+ if (req.path.startsWith('/internal/')) return next();
+ const json = res.json.bind(res);
+ res.json = (body) => json(normalizeTimes(body));
+ next();
+}
+
+module.exports = { TIME_KEY, SQL_TEXT_TIME, isoTime, normalizeTimes, isoJsonTimes };
new file mode 100644
new file mode 100644
@@ -0,0 +1,59 @@
@@ -0,0 +1,59 @@
+'use strict';
+/**
+ * Times leave with their zone (openvibe.design rule 148): Network's SQLite-era text timestamps are UTC
+ * but a browser reads `YYYY-MM-DD HH:MM:SS` as local time. The JSON edge renders time fields as ISO
+ * 8601 with Z; stored text, cursors and every query that compares it stay unchanged.
+ */
+const assert = require('assert');
+const crypto = require('crypto');
+const http = require('http');
+const express = require('express');
+const { getDb } = require('../server/db/database');
+const { isoTime, normalizeTimes, isoJsonTimes } = require('../server/iso-times');
+
+assert.strictEqual(isoTime('2026-11-12 05:01:02'), '2026-11-12T05:01:02Z');
+assert.strictEqual(isoTime('2026-11-12T05:01:02.123Z'), '2026-11-12T05:01:02.123Z', 'ISO with Z is left alone');
+assert.strictEqual(isoTime('2026-11-12T05:01:02+02:00'), '2026-11-12T05:01:02+02:00', 'ISO with an offset is left alone');
+assert.strictEqual(isoTime('2026-11-12'), '2026-11-12', 'a date is not a time');
+assert.strictEqual(isoTime(null), null);
+assert.deepStrictEqual(
+ normalizeTimes({ id: 1, created_at: '2026-11-12 05:01:02', updated_at: null, when: '2026-11-12 05:01:02', meta: { last_seen: '2026-11-12 05:01:02' }, tags: [{ expires_at: '2026-11-12 05:01:02' }] }),
+ { id: 1, created_at: '2026-11-12T05:01:02Z', updated_at: null, when: '2026-11-12 05:01:02', meta: { last_seen: '2026-11-12T05:01:02Z' }, tags: [{ expires_at: '2026-11-12T05:01:02Z' }] },
+ 'time-named fields are rendered; body text that looks like a time is not',
+);
+
+(async () => {
+ const log = console.log; console.log = () => {};
+ const db = getDb();
+ console.log = log;
+ await db.prepare("INSERT INTO users (id, username, password_hash, role) VALUES (41, 'zone', 'x', 'user') ON CONFLICT DO NOTHING").run();
+ for (const [id, name] of [['vibe', 'Vibe'], ['cloud', 'Cloud']]) {
+ await db.prepare("INSERT INTO themes (id, name, slug, mode, variables, is_builtin, is_public) VALUES (?, ?, ?, 'dark', '{}', 1, 1) ON CONFLICT DO NOTHING").run(id, name, id);
+ }
+ const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
+ const config = { jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } };
+ const { signToken } = require('../server/auth/routes');
+ const app = express();
+ app.use(express.json());
+ app.use(isoJsonTimes);
+ Object.assign(app.locals, { db, config, privateKey: keys.privateKey, publicKey: keys.publicKey });
+ app.use('/api/themes', require('../server/themes/routes'));
+ const server = http.createServer(app);
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const token = await signToken(await db.prepare('SELECT * FROM users WHERE id = 41').get(), keys.privateKey, config);
+ const call = async (method, p, body) => fetch(base + p, { method, headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, body: body ? JSON.stringify(body) : undefined }).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));
+
+ await call('PUT', '/api/themes/me', { theme_id: 'vibe' });
+ await call('PUT', '/api/themes/me', { theme_id: 'cloud' });
+ const r = await call('GET', '/api/themes/me/history');
+ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
+ assert.ok(r.body.history.length > 0, 'the history has the replaced setting');
+ for (const entry of r.body.history) assert.match(entry.created_at, /Z$/, 'theme history created_at is ISO with Z');
+ const row = await db.prepare('SELECT created_at FROM user_theme_history WHERE user_id = 41 ORDER BY id DESC LIMIT 1').get();
+ assert.match(row.created_at, /^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d$/, 'the stored text is untouched');
+
+ await new Promise((r2) => server.close(r2));
+ console.log('iso times: helper and GET /api/themes/me/history checked');
+ process.exit(0);
+})().catch((err) => { console.error(err); process.exit(1); });